mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
Features #14-17: WordPress Manager, Backup, Cloudflare, TOTP 2FA
- WordPressManager.php: wp-cli wrapper for install/update/clone/delete - BackupManager.php: tar+mysqldump, schedules, retention, rclone - CloudflareManager.php: zone/record management, sync, cache purge - TOTP.php: RFC 6238 pure-PHP with backup codes - Auth.php: TOTP_REQUIRED two-step login flow - 4 new API endpoints: wordpress, backup, cloudflare, totp - DB migration 002: TOTP cols, CF cols, wordpress_installs, backups tables - admin.js: full UI for all 4 features + TOTP login step - admin/index.php: sidebar nav for WordPress, 2FA Manager, Cloudflare Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
-2
@@ -1,8 +1,13 @@
|
||||
<?php
|
||||
if (!class_exists('TOTP')) require_once __DIR__ . '/TOTP.php';
|
||||
|
||||
class Auth {
|
||||
private static ?Auth $instance = null;
|
||||
private ?array $user = null;
|
||||
|
||||
// Returned by attempt() when password is correct but TOTP code still needed
|
||||
public const TOTP_REQUIRED = 'TOTP_REQUIRED';
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function getInstance(): self {
|
||||
@@ -54,14 +59,36 @@ class Auth {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attempt(string $username, string $password): ?string {
|
||||
$db = DB::getInstance();
|
||||
/**
|
||||
* Returns null (bad credentials), self::TOTP_REQUIRED (need 2FA code), or session token string.
|
||||
*/
|
||||
public function attempt(string $username, string $password, ?string $totpCode = null): ?string {
|
||||
$db = DB::getInstance();
|
||||
$user = $db->fetchOne(
|
||||
"SELECT * FROM users WHERE (username = ? OR email = ?) AND status = 'active'",
|
||||
[$username, $username]
|
||||
);
|
||||
if (!$user || !password_verify($password, $user['password'])) return null;
|
||||
|
||||
// TOTP check
|
||||
if (!empty($user['totp_enabled'])) {
|
||||
if ($totpCode === null) {
|
||||
$this->user = $user;
|
||||
return self::TOTP_REQUIRED;
|
||||
}
|
||||
$verified = TOTP::verify($user['totp_secret'] ?? '', $totpCode);
|
||||
if (!$verified && !empty($user['totp_backup_codes'])) {
|
||||
$verified = TOTP::verifyBackupCode($totpCode, $user['totp_backup_codes']);
|
||||
if ($verified) {
|
||||
// Consume used backup code
|
||||
$hashes = json_decode($user['totp_backup_codes'], true) ?? [];
|
||||
$hashes = array_values(array_filter($hashes, fn($h) => !password_verify(strtoupper($totpCode), $h)));
|
||||
$db->execute("UPDATE users SET totp_backup_codes=? WHERE id=?", [json_encode($hashes), $user['id']]);
|
||||
}
|
||||
}
|
||||
if (!$verified) return null;
|
||||
}
|
||||
|
||||
// Create session
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$sessionId = hash('sha256', $token);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
class BackupManager {
|
||||
private PDO $db;
|
||||
private string $backupRoot = '/home/novacpx-backups';
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance()->getPDO();
|
||||
if (!is_dir($this->backupRoot)) mkdir($this->backupRoot, 0750, true);
|
||||
}
|
||||
|
||||
// ── Create full backup ────────────────────────────────────────────────────
|
||||
public function create(int $accountId, string $type = 'full'): array {
|
||||
$account = $this->getAccount($accountId);
|
||||
$dir = $this->backupRoot . '/' . $account['username'];
|
||||
if (!is_dir($dir)) mkdir($dir, 0750, true);
|
||||
|
||||
$ts = date('Ymd_His');
|
||||
$filename = "{$account['username']}_{$type}_{$ts}.tar.gz";
|
||||
$filepath = "{$dir}/{$filename}";
|
||||
|
||||
// Record as pending
|
||||
$stmt = $this->db->prepare("INSERT INTO backups (account_id, filename, type, status, storage) VALUES (?,?,?,'running','local')");
|
||||
$stmt->execute([$accountId, $filename, $type]);
|
||||
$backupId = $this->db->lastInsertId();
|
||||
|
||||
try {
|
||||
if ($type === 'full' || $type === 'files') {
|
||||
$docRoot = escapeshellarg($account['document_root']);
|
||||
exec("tar -czf " . escapeshellarg($filepath) . " -C / " . ltrim($docRoot, '/') . " 2>&1", $out, $rc);
|
||||
if ($rc !== 0) throw new RuntimeException("tar failed: " . implode("\n", $out));
|
||||
}
|
||||
|
||||
if ($type === 'full' || $type === 'database') {
|
||||
// Dump all databases belonging to this account
|
||||
$dbs = $this->db->prepare("SELECT db_name FROM account_databases WHERE account_id=?");
|
||||
$dbs->execute([$accountId]);
|
||||
foreach ($dbs->fetchAll(PDO::FETCH_COLUMN) as $dbName) {
|
||||
$dumpFile = escapeshellarg("{$dir}/{$account['username']}_{$dbName}_{$ts}.sql.gz");
|
||||
exec("mysqldump " . escapeshellarg($dbName) . " | gzip > {$dumpFile} 2>&1");
|
||||
}
|
||||
|
||||
if ($type === 'database') {
|
||||
// Pack all sql.gz files into the tar
|
||||
exec("tar -czf " . escapeshellarg($filepath) . " -C {$dir} " .
|
||||
escapeshellarg("{$account['username']}_{$ts}") . "*.sql.gz 2>/dev/null");
|
||||
}
|
||||
}
|
||||
|
||||
$size = file_exists($filepath) ? filesize($filepath) : 0;
|
||||
$this->db->prepare("UPDATE backups SET status='complete', size=? WHERE id=?")->execute([$size, $backupId]);
|
||||
return ['id' => $backupId, 'filename' => $filename, 'size' => $size];
|
||||
|
||||
} catch (RuntimeException $e) {
|
||||
$this->db->prepare("UPDATE backups SET status='failed' WHERE id=?")->execute([$backupId]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────────────────
|
||||
public function list(int $accountId = 0): array {
|
||||
if ($accountId) {
|
||||
$stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.account_id=? ORDER BY b.created_at DESC");
|
||||
$stmt->execute([$accountId]);
|
||||
} else {
|
||||
$stmt = $this->db->query("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id ORDER BY b.created_at DESC");
|
||||
}
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────────────────
|
||||
public function getDownloadPath(int $backupId): string {
|
||||
$stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
|
||||
$stmt->execute([$backupId]);
|
||||
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$backup) throw new RuntimeException("Backup not found");
|
||||
$path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
|
||||
if (!file_exists($path)) throw new RuntimeException("Backup file missing from disk");
|
||||
return $path;
|
||||
}
|
||||
|
||||
// ── Restore ───────────────────────────────────────────────────────────────
|
||||
public function restore(int $backupId): bool {
|
||||
$stmt = $this->db->prepare("SELECT b.*, a.document_root, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
|
||||
$stmt->execute([$backupId]);
|
||||
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$backup) throw new RuntimeException("Backup not found");
|
||||
|
||||
$path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
|
||||
if (!file_exists($path)) throw new RuntimeException("Backup file not found on disk");
|
||||
|
||||
exec("tar -xzf " . escapeshellarg($path) . " -C / 2>&1", $out, $rc);
|
||||
if ($rc !== 0) throw new RuntimeException("Restore failed: " . implode("\n", $out));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
public function delete(int $backupId): bool {
|
||||
$stmt = $this->db->prepare("SELECT b.*, a.username FROM backups b JOIN accounts a ON b.account_id=a.id WHERE b.id=?");
|
||||
$stmt->execute([$backupId]);
|
||||
$backup = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($backup) {
|
||||
$path = $this->backupRoot . '/' . $backup['username'] . '/' . $backup['filename'];
|
||||
if (file_exists($path)) unlink($path);
|
||||
$this->db->prepare("DELETE FROM backups WHERE id=?")->execute([$backupId]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Schedule ──────────────────────────────────────────────────────────────
|
||||
public function setSchedule(int $accountId, string $frequency, string $type = 'full', int $retain = 7): bool {
|
||||
$stmt = $this->db->prepare("INSERT INTO backup_schedules (account_id, frequency, type, retain_count)
|
||||
VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE frequency=VALUES(frequency), type=VALUES(type), retain_count=VALUES(retain_count)");
|
||||
$stmt->execute([$accountId, $frequency, $type, $retain]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getSchedule(int $accountId): ?array {
|
||||
$stmt = $this->db->prepare("SELECT * FROM backup_schedules WHERE account_id=?");
|
||||
$stmt->execute([$accountId]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
// ── Prune old backups per retention policy ────────────────────────────────
|
||||
public function prune(int $accountId): int {
|
||||
$schedule = $this->getSchedule($accountId);
|
||||
if (!$schedule) return 0;
|
||||
$retain = (int)$schedule['retain_count'];
|
||||
$stmt = $this->db->prepare("SELECT * FROM backups WHERE account_id=? AND status='complete' ORDER BY created_at DESC");
|
||||
$stmt->execute([$accountId]);
|
||||
$all = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$pruned = 0;
|
||||
foreach (array_slice($all, $retain) as $old) {
|
||||
$this->delete($old['id']);
|
||||
$pruned++;
|
||||
}
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
// ── rclone remote upload ──────────────────────────────────────────────────
|
||||
public function uploadRemote(int $backupId, string $remote): string {
|
||||
$path = $this->getDownloadPath($backupId);
|
||||
$out = []; exec("rclone copy " . escapeshellarg($path) . " " . escapeshellarg($remote) . " 2>&1", $out, $rc);
|
||||
if ($rc === 0) {
|
||||
$this->db->prepare("UPDATE backups SET storage='remote', remote_path=? WHERE id=?")->execute([$remote, $backupId]);
|
||||
}
|
||||
return implode("\n", $out);
|
||||
}
|
||||
|
||||
// ── Disk usage ────────────────────────────────────────────────────────────
|
||||
public function diskUsage(int $accountId = 0): int {
|
||||
if ($accountId) {
|
||||
$stmt = $this->db->prepare("SELECT COALESCE(SUM(size),0) FROM backups WHERE account_id=? AND status='complete'");
|
||||
$stmt->execute([$accountId]);
|
||||
} else {
|
||||
$stmt = $this->db->query("SELECT COALESCE(SUM(size),0) FROM backups WHERE status='complete'");
|
||||
}
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
private function getAccount(int $id): array {
|
||||
$stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
class CloudflareManager {
|
||||
private const API = 'https://api.cloudflare.com/client/v4/';
|
||||
private PDO $db;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance()->getPDO();
|
||||
}
|
||||
|
||||
// ── Credential management ─────────────────────────────────────────────────
|
||||
public function saveCredentials(int $accountId, string $apiKey, string $email): bool {
|
||||
$stmt = $this->db->prepare("UPDATE accounts SET cf_api_key=?, cf_api_email=? WHERE id=?");
|
||||
return $stmt->execute([$apiKey, $email, $accountId]);
|
||||
}
|
||||
|
||||
public function testCredentials(string $apiKey, string $email): bool {
|
||||
$r = $this->req('GET', 'user/tokens/verify', [], $apiKey, $email);
|
||||
return $r['success'] ?? false;
|
||||
}
|
||||
|
||||
public function getCredentials(int $accountId): ?array {
|
||||
$stmt = $this->db->prepare("SELECT cf_api_key, cf_api_email, cf_zone_id FROM accounts WHERE id=?");
|
||||
$stmt->execute([$accountId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return ($row && $row['cf_api_key']) ? $row : null;
|
||||
}
|
||||
|
||||
// ── Zones ─────────────────────────────────────────────────────────────────
|
||||
public function listZones(string $apiKey, string $email): array {
|
||||
$r = $this->req('GET', 'zones?per_page=200&status=active', [], $apiKey, $email);
|
||||
return $r['result'] ?? [];
|
||||
}
|
||||
|
||||
public function getZoneId(string $domain, string $apiKey, string $email): ?string {
|
||||
$r = $this->req('GET', 'zones?name=' . urlencode($domain), [], $apiKey, $email);
|
||||
return $r['result'][0]['id'] ?? null;
|
||||
}
|
||||
|
||||
// ── DNS records ───────────────────────────────────────────────────────────
|
||||
public function listRecords(string $zoneId, string $apiKey, string $email): array {
|
||||
$r = $this->req('GET', "zones/{$zoneId}/dns_records?per_page=200", [], $apiKey, $email);
|
||||
return $r['result'] ?? [];
|
||||
}
|
||||
|
||||
public function createRecord(string $zoneId, array $record, string $apiKey, string $email): array {
|
||||
return $this->req('POST', "zones/{$zoneId}/dns_records", $record, $apiKey, $email);
|
||||
}
|
||||
|
||||
public function updateRecord(string $zoneId, string $recordId, array $record, string $apiKey, string $email): array {
|
||||
return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $record, $apiKey, $email);
|
||||
}
|
||||
|
||||
public function deleteRecord(string $zoneId, string $recordId, string $apiKey, string $email): bool {
|
||||
$r = $this->req('DELETE', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email);
|
||||
return $r['success'] ?? false;
|
||||
}
|
||||
|
||||
public function toggleProxy(string $zoneId, string $recordId, bool $proxied, string $apiKey, string $email): array {
|
||||
// Fetch existing record first
|
||||
$r = $this->req('GET', "zones/{$zoneId}/dns_records/{$recordId}", [], $apiKey, $email);
|
||||
$rec = $r['result'] ?? [];
|
||||
$rec['proxied'] = $proxied;
|
||||
unset($rec['id'], $rec['zone_id'], $rec['zone_name'], $rec['created_on'], $rec['modified_on'], $rec['meta']);
|
||||
return $this->req('PUT', "zones/{$zoneId}/dns_records/{$recordId}", $rec, $apiKey, $email);
|
||||
}
|
||||
|
||||
// ── Sync: push local DNS records to Cloudflare ────────────────────────────
|
||||
public function syncToCloudflare(string $domain, string $zoneId, string $apiKey, string $email): array {
|
||||
$localRecords = $this->getLocalRecords($domain);
|
||||
$cfRecords = $this->listRecords($zoneId, $apiKey, $email);
|
||||
$cfIndex = [];
|
||||
foreach ($cfRecords as $r) $cfIndex[$r['type'] . '_' . $r['name']] = $r;
|
||||
|
||||
$created = 0; $updated = 0;
|
||||
foreach ($localRecords as $local) {
|
||||
$key = $local['type'] . '_' . $local['name'] . '.' . $domain . '.';
|
||||
if (isset($cfIndex[$key])) {
|
||||
$this->updateRecord($zoneId, $cfIndex[$key]['id'], [
|
||||
'type' => $local['type'],
|
||||
'name' => $local['name'],
|
||||
'content' => $local['content'],
|
||||
'ttl' => (int)($local['ttl'] ?? 1),
|
||||
'proxied' => in_array($local['type'], ['A','AAAA','CNAME']),
|
||||
], $apiKey, $email);
|
||||
$updated++;
|
||||
} else {
|
||||
$this->createRecord($zoneId, [
|
||||
'type' => $local['type'],
|
||||
'name' => $local['name'],
|
||||
'content' => $local['content'],
|
||||
'ttl' => (int)($local['ttl'] ?? 1),
|
||||
'proxied' => in_array($local['type'], ['A','AAAA','CNAME']),
|
||||
], $apiKey, $email);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
return ['created' => $created, 'updated' => $updated];
|
||||
}
|
||||
|
||||
// ── Sync: pull Cloudflare records into local DB ───────────────────────────
|
||||
public function syncFromCloudflare(string $domain, string $zoneId, string $apiKey, string $email): int {
|
||||
$cfRecords = $this->listRecords($zoneId, $apiKey, $email);
|
||||
$count = 0;
|
||||
foreach ($cfRecords as $rec) {
|
||||
$name = rtrim(str_replace('.' . $domain, '', $rec['name']), '.');
|
||||
$this->db->prepare("INSERT INTO dns_records (domain, name, type, content, ttl, priority)
|
||||
VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE content=VALUES(content), ttl=VALUES(ttl)")
|
||||
->execute([$domain, $name ?: '@', $rec['type'], $rec['content'], $rec['ttl'] ?? 300, $rec['priority'] ?? 0]);
|
||||
$count++;
|
||||
}
|
||||
// Store zone ID on domain
|
||||
$this->db->prepare("UPDATE dns_zones SET cf_zone_id=? WHERE domain=?")->execute([$zoneId, $domain]);
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ── Purge cache ───────────────────────────────────────────────────────────
|
||||
public function purgeCache(string $zoneId, string $apiKey, string $email): bool {
|
||||
$r = $this->req('POST', "zones/{$zoneId}/purge_cache", ['purge_everything' => true], $apiKey, $email);
|
||||
return $r['success'] ?? false;
|
||||
}
|
||||
|
||||
// ── HTTP helper ───────────────────────────────────────────────────────────
|
||||
private function req(string $method, string $path, array $body, string $apiKey, string $email): array {
|
||||
$ch = curl_init(self::API . ltrim($path, '/'));
|
||||
$headers = [
|
||||
"X-Auth-Email: {$email}",
|
||||
"X-Auth-Key: {$apiKey}",
|
||||
"Content-Type: application/json",
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
if ($body && in_array($method, ['POST','PUT','PATCH'])) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return json_decode($result, true) ?? ['success' => false, 'errors' => ['curl failed']];
|
||||
}
|
||||
|
||||
private function getLocalRecords(string $domain): array {
|
||||
$stmt = $this->db->prepare("SELECT * FROM dns_records WHERE domain=?");
|
||||
$stmt->execute([$domain]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
class TOTP {
|
||||
private const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
private const PERIOD = 30;
|
||||
private const DIGITS = 6;
|
||||
private const WINDOW = 1;
|
||||
|
||||
public static function generateSecret(int $length = 32): string {
|
||||
$secret = '';
|
||||
$bytes = random_bytes($length);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$secret .= self::CHARS[ord($bytes[$i]) & 31];
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
public static function generateCode(string $secret, ?int $timestamp = null): string {
|
||||
$counter = intdiv($timestamp ?? time(), self::PERIOD);
|
||||
$key = self::base32Decode($secret);
|
||||
$msg = pack('J', $counter);
|
||||
$hash = hash_hmac('sha1', $msg, $key, true);
|
||||
$offset = ord($hash[19]) & 0x0F;
|
||||
$code = ((ord($hash[$offset]) & 0x7F) << 24)
|
||||
| ((ord($hash[$offset+1]) & 0xFF) << 16)
|
||||
| ((ord($hash[$offset+2]) & 0xFF) << 8)
|
||||
| (ord($hash[$offset+3]) & 0xFF);
|
||||
return str_pad($code % (10 ** self::DIGITS), self::DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $code): bool {
|
||||
$time = time();
|
||||
for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) {
|
||||
if (hash_equals(self::generateCode($secret, $time + $i * self::PERIOD), $code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function qrUrl(string $secret, string $username, string $issuer = 'NovaCPX'): string {
|
||||
$label = rawurlencode("{$issuer}:{$username}");
|
||||
$otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer) . "&algorithm=SHA1&digits=6&period=30";
|
||||
return "https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=" . rawurlencode($otpauth);
|
||||
}
|
||||
|
||||
public static function generateBackupCodes(int $count = 8): array {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$raw = bin2hex(random_bytes(4));
|
||||
$codes[] = strtoupper(substr($raw, 0, 4) . '-' . substr($raw, 4, 4));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
public static function hashBackupCodes(array $codes): string {
|
||||
return json_encode(array_map(fn($c) => password_hash($c, PASSWORD_BCRYPT), $codes));
|
||||
}
|
||||
|
||||
public static function verifyBackupCode(string $code, string $hashedJson): bool {
|
||||
$hashes = json_decode($hashedJson, true) ?? [];
|
||||
foreach ($hashes as $hash) {
|
||||
if (password_verify(strtoupper($code), $hash)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function base32Decode(string $base32): string {
|
||||
$base32 = strtoupper(preg_replace('/[^A-Z2-7]/', '', $base32));
|
||||
$buf = 0; $bits = 0; $out = '';
|
||||
for ($i = 0; $i < strlen($base32); $i++) {
|
||||
$val = strpos(self::CHARS, $base32[$i]);
|
||||
$buf = ($buf << 5) | $val;
|
||||
$bits += 5;
|
||||
if ($bits >= 8) { $bits -= 8; $out .= chr(($buf >> $bits) & 0xFF); }
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
class WordPressManager {
|
||||
private PDO $db;
|
||||
private string $wpcli = '/usr/local/bin/wp';
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance()->getPDO();
|
||||
$this->ensureWpCli();
|
||||
}
|
||||
|
||||
// ── Install ───────────────────────────────────────────────────────────────
|
||||
public function install(int $accountId, string $domain, string $path,
|
||||
string $adminUser, string $adminEmail, string $adminPass,
|
||||
string $siteTitle): array {
|
||||
$account = $this->getAccount($accountId);
|
||||
$docRoot = $account['document_root'] . rtrim($path, '/');
|
||||
$dbName = 'wp_' . preg_replace('/[^a-z0-9]/', '_', strtolower($account['username'])) . '_' . substr(md5($domain), 0, 6);
|
||||
$dbPass = bin2hex(random_bytes(12));
|
||||
$dbUser = substr($dbName, 0, 32);
|
||||
|
||||
// Create DB
|
||||
$this->db->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$this->db->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'");
|
||||
$this->db->exec("GRANT ALL ON `{$dbName}`.* TO '{$dbUser}'@'localhost'");
|
||||
|
||||
// Download WP + install
|
||||
$sysUser = $account['system_user'] ?? 'www-data';
|
||||
$this->wp($docRoot, "core download --locale=en_US", $sysUser);
|
||||
$this->wp($docRoot, "config create --dbname={$dbName} --dbuser={$dbUser} --dbpass={$dbPass} --dbhost=localhost --skip-check", $sysUser);
|
||||
$this->wp($docRoot, sprintf(
|
||||
'core install --url=https://%s --title="%s" --admin_user=%s --admin_password=%s --admin_email=%s --skip-email',
|
||||
escapeshellarg($domain . $path), escapeshellarg($siteTitle),
|
||||
escapeshellarg($adminUser), escapeshellarg($adminPass), escapeshellarg($adminEmail)
|
||||
), $sysUser);
|
||||
|
||||
// Store in DB
|
||||
$stmt = $this->db->prepare("INSERT INTO wordpress_installs
|
||||
(account_id, domain, path, db_name, db_user, db_pass, admin_user, admin_email, wp_version, status)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)");
|
||||
$stmt->execute([$accountId, $domain, $path, $dbName, $dbUser, $dbPass, $adminUser, $adminEmail,
|
||||
$this->getVersion($docRoot, $sysUser), 'active']);
|
||||
$id = $this->db->lastInsertId();
|
||||
|
||||
return ['id' => $id, 'db_name' => $dbName, 'admin_user' => $adminUser, 'admin_pass' => $adminPass];
|
||||
}
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────────────────
|
||||
public function list(int $accountId = 0): array {
|
||||
$sql = $accountId
|
||||
? "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.account_id=? ORDER BY w.created_at DESC"
|
||||
: "SELECT w.*, a.domain as account_domain FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id ORDER BY w.created_at DESC";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$accountId ? $stmt->execute([$accountId]) : $stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
public function updateCore(int $id): string {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$out = $this->wp($docRoot, 'core update', $sysUser);
|
||||
$ver = $this->getVersion($docRoot, $sysUser);
|
||||
$this->db->prepare("UPDATE wordpress_installs SET wp_version=? WHERE id=?")->execute([$ver, $id]);
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function updatePlugins(int $id): string {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
return $this->wp($docRoot, 'plugin update --all', $sysUser);
|
||||
}
|
||||
|
||||
public function updateThemes(int $id): string {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
return $this->wp($docRoot, 'theme update --all', $sysUser);
|
||||
}
|
||||
|
||||
// ── Staging clone ─────────────────────────────────────────────────────────
|
||||
public function cloneStaging(int $id): array {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$stagingPath = $install['path'] . '_staging';
|
||||
$stagingDomain = 'staging.' . $install['domain'];
|
||||
$stagingRoot = dirname($docRoot) . rtrim($stagingPath, '/');
|
||||
|
||||
// Copy files
|
||||
$this->exec("cp -r {$docRoot} {$stagingRoot}");
|
||||
|
||||
// Clone DB
|
||||
$stagingDb = $install['db_name'] . '_staging';
|
||||
$stagingDbPw = bin2hex(random_bytes(8));
|
||||
$this->db->exec("CREATE DATABASE IF NOT EXISTS `{$stagingDb}`");
|
||||
$this->db->exec("CREATE USER IF NOT EXISTS '{$stagingDb}'@'localhost' IDENTIFIED BY '{$stagingDbPw}'");
|
||||
$this->db->exec("GRANT ALL ON `{$stagingDb}`.* TO '{$stagingDb}'@'localhost'");
|
||||
$this->exec("mysqldump {$install['db_name']} | mysql {$stagingDb}");
|
||||
|
||||
// Update staging wp-config
|
||||
$this->wp($stagingRoot, "config set DB_NAME {$stagingDb}", $sysUser);
|
||||
$this->wp($stagingRoot, "config set DB_USER {$stagingDb}", $sysUser);
|
||||
$this->wp($stagingRoot, "config set DB_PASSWORD {$stagingDbPw}", $sysUser);
|
||||
$this->wp($stagingRoot, "search-replace https://{$install['domain']} https://{$stagingDomain} --all-tables", $sysUser);
|
||||
|
||||
// Record staging install
|
||||
$stmt = $this->db->prepare("INSERT INTO wordpress_installs
|
||||
(account_id,domain,path,db_name,db_user,db_pass,admin_user,admin_email,wp_version,status,staging_of)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||
$stmt->execute([$install['account_id'], $stagingDomain, $stagingPath,
|
||||
$stagingDb, $stagingDb, $stagingDbPw,
|
||||
$install['admin_user'], $install['admin_email'], $install['wp_version'], 'active', $id]);
|
||||
|
||||
return ['domain' => $stagingDomain, 'path' => $stagingPath];
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
public function delete(int $id): bool {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$this->exec("rm -rf {$docRoot}");
|
||||
$this->db->exec("DROP DATABASE IF EXISTS `{$install['db_name']}`");
|
||||
$this->db->exec("DROP USER IF EXISTS '{$install['db_user']}'@'localhost'");
|
||||
$this->db->prepare("DELETE FROM wordpress_installs WHERE id=?")->execute([$id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Info ──────────────────────────────────────────────────────────────────
|
||||
public function info(int $id): array {
|
||||
[$install, $sysUser, $docRoot] = $this->resolve($id);
|
||||
$plugins = $this->wp($docRoot, 'plugin list --format=json', $sysUser);
|
||||
$themes = $this->wp($docRoot, 'theme list --format=json', $sysUser);
|
||||
return [
|
||||
'install' => $install,
|
||||
'plugins' => json_decode($plugins, true) ?? [],
|
||||
'themes' => json_decode($themes, true) ?? [],
|
||||
'version' => $this->getVersion($docRoot, $sysUser),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
private function wp(string $path, string $cmd, string $user): string {
|
||||
$safe = escapeshellarg($path);
|
||||
$out = []; $rc = 0;
|
||||
exec("sudo -u {$user} {$this->wpcli} --path={$safe} --allow-root {$cmd} 2>&1", $out, $rc);
|
||||
return implode("\n", $out);
|
||||
}
|
||||
|
||||
private function exec(string $cmd): string {
|
||||
$out = []; exec($cmd . ' 2>&1', $out); return implode("\n", $out);
|
||||
}
|
||||
|
||||
private function getVersion(string $path, string $user): string {
|
||||
$v = trim($this->wp($path, 'core version', $user));
|
||||
return $v ?: 'unknown';
|
||||
}
|
||||
|
||||
private function resolve(int $id): array {
|
||||
$stmt = $this->db->prepare("SELECT w.*, a.document_root, a.system_user, a.username FROM wordpress_installs w JOIN accounts a ON w.account_id=a.id WHERE w.id=?");
|
||||
$stmt->execute([$id]);
|
||||
$install = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$install) throw new RuntimeException("WordPress install #{$id} not found");
|
||||
$docRoot = $install['document_root'] . rtrim($install['path'], '/');
|
||||
$sysUser = $install['system_user'] ?? 'www-data';
|
||||
return [$install, $sysUser, $docRoot];
|
||||
}
|
||||
|
||||
private function getAccount(int $id): array {
|
||||
$stmt = $this->db->prepare("SELECT * FROM accounts WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: throw new RuntimeException("Account #{$id} not found");
|
||||
}
|
||||
|
||||
private function ensureWpCli(): void {
|
||||
if (!file_exists($this->wpcli)) {
|
||||
file_put_contents('/tmp/wp-cli.phar', file_get_contents('https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'));
|
||||
rename('/tmp/wp-cli.phar', $this->wpcli);
|
||||
chmod($this->wpcli, 0755);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user