diff --git a/db/migrations/010_email_enc_password.sql b/db/migrations/010_email_enc_password.sql new file mode 100644 index 0000000..71128a8 --- /dev/null +++ b/db/migrations/010_email_enc_password.sql @@ -0,0 +1 @@ +ALTER TABLE email_accounts ADD COLUMN enc_password TEXT; diff --git a/db/schema.sql b/db/schema.sql index af8f395..f3301e9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -183,9 +183,10 @@ CREATE INDEX IF NOT EXISTS idx_dns_records_type ON dns_records (type); CREATE TABLE IF NOT EXISTS email_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - quota_mb INTEGER DEFAULT 500, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + enc_password TEXT, + quota_mb INTEGER DEFAULT 500, used_mb INTEGER DEFAULT 0, status TEXT DEFAULT 'active' CHECK(status IN ('active','suspended')), created_at TEXT DEFAULT (datetime('now')), diff --git a/panel/api/endpoints/domains.php b/panel/api/endpoints/domains.php index a523a58..4cd727a 100644 --- a/panel/api/endpoints/domains.php +++ b/panel/api/endpoints/domains.php @@ -41,13 +41,7 @@ match ($action) { @mkdir($docRoot, 0755, true); file_put_contents("$docRoot/index.html", "

$domain is ready!

Upload your website files.

"); - VhostManager::create([ - 'domain' => $domain, - 'username' => $acct['username'], - 'home_dir' => $acct['home_dir'], - 'doc_root' => $docRoot, - 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT, - ]); + VhostManager::create($acct['username'], $domain, $docRoot, $acct['php_version'] ?? PHP_DEFAULT); DNSManager::createZone($accountId, $domain); audit('domains.add-addon', $domain); Response::success(null, "Addon domain $domain added"); @@ -67,13 +61,7 @@ match ($action) { "INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())", [$accountId, $full, 'subdomain', $docRoot] ); - VhostManager::create([ - 'domain' => $full, - 'username' => $acct['username'], - 'home_dir' => $acct['home_dir'], - 'doc_root' => $docRoot, - 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT, - ]); + VhostManager::create($acct['username'], $full, $docRoot, $acct['php_version'] ?? PHP_DEFAULT); $zone = DB::getInstance()->fetchOne("SELECT id FROM dns_zones WHERE domain = ? AND account_id = ?", [$parent, $accountId]); if ($zone) DNSManager::addRecord((int)$zone['id'], $sub, 'A', gethostbyname(gethostname())); audit('domains.add-subdomain', $full); @@ -89,13 +77,7 @@ match ($action) { "INSERT INTO domains (account_id, domain, type, document_root, created_at) VALUES (?,?,?,?,NOW())", [$accountId, $alias, 'alias', $acct['home_dir'] . '/public_html'] ); - VhostManager::create([ - 'domain' => $alias, - 'username' => $acct['username'], - 'home_dir' => $acct['home_dir'], - 'doc_root' => $acct['home_dir'] . '/public_html', - 'php_ver' => $acct['php_version'] ?? PHP_DEFAULT, - ]); + VhostManager::create($acct['username'], $alias, $acct['home_dir'] . '/public_html', $acct['php_version'] ?? PHP_DEFAULT); DNSManager::createZone($accountId, $alias); audit('domains.add-alias', $alias); Response::success(null, "Domain alias $alias added"); diff --git a/panel/lib/BackupManager.php b/panel/lib/BackupManager.php index 233e80d..f26f7c9 100644 --- a/panel/lib/BackupManager.php +++ b/panel/lib/BackupManager.php @@ -46,9 +46,10 @@ class BackupManager { } } - $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]; + $bytes = file_exists($filepath) ? filesize($filepath) : 0; + $sizeMb = round($bytes / 1048576, 2); + $this->db->prepare("UPDATE backups SET status='complete', size_mb=? WHERE id=?")->execute([$sizeMb, $backupId]); + return ['id' => $backupId, 'filename' => $filename, 'size_mb' => $sizeMb]; } catch (RuntimeException $e) { $this->db->prepare("UPDATE backups SET status='failed' WHERE id=?")->execute([$backupId]); @@ -147,14 +148,14 @@ class BackupManager { } // ── Disk usage ──────────────────────────────────────────────────────────── - public function diskUsage(int $accountId = 0): int { + public function diskUsage(int $accountId = 0): float { if ($accountId) { - $stmt = $this->db->prepare("SELECT COALESCE(SUM(size),0) FROM backups WHERE account_id=? AND status='complete'"); + $stmt = $this->db->prepare("SELECT COALESCE(SUM(size_mb),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'"); + $stmt = $this->db->query("SELECT COALESCE(SUM(size_mb),0) FROM backups WHERE status='complete'"); } - return (int)$stmt->fetchColumn(); + return (float)$stmt->fetchColumn(); } private function getAccount(int $id): array { diff --git a/panel/lib/DB.php b/panel/lib/DB.php index f2622e6..eeae28d 100644 --- a/panel/lib/DB.php +++ b/panel/lib/DB.php @@ -93,6 +93,9 @@ class DB { $sql ); + // LAST_INSERT_ID() → last_insert_rowid() + $sql = preg_replace('/\bLAST_INSERT_ID\(\)/i', 'last_insert_rowid()', $sql); + // IFNULL → COALESCE (SQLite supports both but be safe) $sql = preg_replace('/\bIFNULL\s*\(/i', 'COALESCE(', $sql); diff --git a/panel/lib/DatabaseManager.php b/panel/lib/DatabaseManager.php index c4dcfd7..d6e287e 100644 --- a/panel/lib/DatabaseManager.php +++ b/panel/lib/DatabaseManager.php @@ -4,10 +4,26 @@ */ class DatabaseManager { + private static function mysqlPdo(): PDO { + $cfg = @parse_ini_file('/etc/novacpx/config.ini', true) ?: []; + $host = $cfg['mysql']['host'] ?? '127.0.0.1'; + $port = $cfg['mysql']['port'] ?? '3306'; + $user = $cfg['mysql']['root_user'] ?? 'root'; + $pass = $cfg['mysql']['root_pass'] ?? ''; + $opts = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; + if ($pass === '' && $host === '127.0.0.1') { + // Try socket auth (MariaDB/MySQL root with no password) + try { + return new PDO("mysql:unix_socket=/var/run/mysqld/mysqld.sock", $user, '', $opts); + } catch (PDOException $e) { /* fall through to TCP */ } + } + return new PDO("mysql:host={$host};port={$port}", $user, $pass, $opts); + } + public static function createMySQL(int $accountId, string $dbName, string $dbUser, string $dbPass): int { self::validateName($dbName); self::validateName($dbUser); $db = DB::getInstance(); - $pdo = $db->pdo(); + $pdo = self::mysqlPdo(); $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $pdo->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY " . $pdo->quote($dbPass)); @@ -35,7 +51,7 @@ class DatabaseManager { public static function drop(string $dbName, string $dbUser, string $type = 'mysql'): void { if ($type === 'mysql') { - $pdo = DB::getInstance()->pdo(); + $pdo = self::mysqlPdo(); $pdo->exec("DROP DATABASE IF EXISTS `{$dbName}`"); $pdo->exec("DROP USER IF EXISTS '{$dbUser}'@'localhost'"); $pdo->exec("FLUSH PRIVILEGES"); @@ -51,7 +67,7 @@ class DatabaseManager { $dbe = $db->fetchOne("SELECT * FROM `databases` WHERE id = ?", [$id]); if (!$dbe) throw new RuntimeException("Database not found"); if ($dbe['db_type'] === 'mysql') { - $pdo = $db->pdo(); + $pdo = self::mysqlPdo(); $pdo->exec("ALTER USER '{$dbe['db_user']}'@'localhost' IDENTIFIED BY " . $pdo->quote($newPass)); } else { shell_exec("sudo -u postgres psql -c \"ALTER USER {$dbe['db_user']} WITH PASSWORD " . escapeshellarg($newPass) . "\" 2>/dev/null"); @@ -61,11 +77,12 @@ class DatabaseManager { public static function getSize(string $dbName, string $type = 'mysql'): float { if ($type === 'mysql') { - $row = DB::getInstance()->fetchOne( + $stmt = self::mysqlPdo()->prepare( "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size - FROM information_schema.tables WHERE table_schema = ?", - [$dbName] + FROM information_schema.tables WHERE table_schema = ?" ); + $stmt->execute([$dbName]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); return (float)($row['size'] ?? 0); } $out = shell_exec("sudo -u postgres psql -t -c \"SELECT pg_size_pretty(pg_database_size('{$dbName}'))\" 2>/dev/null"); diff --git a/panel/lib/PHPManager.php b/panel/lib/PHPManager.php index 9e9345a..263bfab 100644 --- a/panel/lib/PHPManager.php +++ b/panel/lib/PHPManager.php @@ -2,16 +2,25 @@ /** * PHPManager — per-account PHP-FPM pools + version switching */ +if (!class_exists('VhostManager')) require_once __DIR__ . '/VhostManager.php'; + class PHPManager { private static string $poolDir = '/etc/php/{ver}/fpm/pool.d'; + private static function writeFile(string $path, string $content): void { + $tmp = tempnam('/tmp', 'ncpx_pool_'); + file_put_contents($tmp, $content); + shell_exec("sudo tee " . escapeshellarg($path) . " > /dev/null < " . escapeshellarg($tmp)); + @unlink($tmp); + } + public static function createPool(string $username, string $phpVer): void { $poolFile = str_replace('{ver}', $phpVer, self::$poolDir) . "/{$username}.conf"; $homeDir = "/home/{$username}"; $sock = "/run/php/php{$phpVer}-fpm-{$username}.sock"; - file_put_contents($poolFile, "[{$username}] + self::writeFile($poolFile, "[{$username}] user = {$username} group = www-data listen = {$sock} @@ -39,7 +48,7 @@ php_value[max_execution_time] = 30 public static function removePool(string $username): void { foreach (['7.4','8.1','8.2','8.3'] as $ver) { $file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf"; - if (file_exists($file)) { unlink($file); self::reloadFPM($ver); } + if (file_exists($file)) { shell_exec("sudo rm -f " . escapeshellarg($file)); self::reloadFPM($ver); } } } @@ -70,9 +79,9 @@ php_value[max_execution_time] = 30 if (!$acct) throw new RuntimeException("Account not found"); $poolFile = str_replace('{ver}', $acct['php_version'], self::$poolDir) . "/{$acct['username']}.conf"; - if (!file_exists($poolFile)) throw new RuntimeException("PHP-FPM pool not found"); + if (!file_exists($poolFile)) self::createPool($acct['username'], $acct['php_version']); - $content = file_get_contents($poolFile); + $content = file_get_contents($poolFile) ?: ''; $map = [ 'memory_limit' => 'php_value[memory_limit]', 'max_execution_time' => 'php_value[max_execution_time]', @@ -84,7 +93,7 @@ php_value[max_execution_time] = 30 $content = preg_replace("/{$iniKey}\s*=.*/", "{$iniKey} = {$cfg[$key]}", $content); } } - file_put_contents($poolFile, $content); + self::writeFile($poolFile, $content); self::reloadFPM($acct['php_version']); $db->execute( diff --git a/panel/public/assets/js/user.js b/panel/public/assets/js/user.js index dcf3bb2..21ba508 100644 --- a/panel/public/assets/js/user.js +++ b/panel/public/assets/js/user.js @@ -240,13 +240,54 @@ window.removeDomain = (id, domain) => { }, true); }; -window.issueSSL = async (domainId, domain) => { - Nova.loading(`Issuing SSL for ${domain}…`); - const res = await Nova.api('ssl', 'issue', { method: 'POST', body: { domain } }); - Nova.loadingDone(); - if (res?.success) { Nova.toast('SSL issued successfully','success'); loadDomainsList(); } - else Nova.toast(res?.message || 'SSL failed — check domain DNS','error',6000); -}; +function _sslStream(params, onSuccess) { + const termId = 'ssl-term-' + Date.now(); + Nova.modal(`SSL: ${params.domain}`, ` +
+ Requesting certificate…\n +
`, + ``); + const term = document.getElementById(termId); + const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; }; + fetch('/api/ssl/issue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + credentials: 'same-origin', + }).then(resp => { + if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + const read = () => reader.read().then(({ done, value }) => { + if (done) { append('\n[done]'); return; } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + const m = part.match(/^data: (.+)$/m); + if (!m) continue; + try { + const obj = JSON.parse(m[1]); + if (obj.line) { append(obj.line); } + else if (obj.done) { + const btn = document.getElementById('ssl-term-close'); + if (btn) { + btn.textContent = obj.success ? 'Done ✓' : 'Close'; + btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost'; + if (obj.success) btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); if (onSuccess) onSuccess(); }; + } + } + } catch(e) {} + } + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); + read(); + }).catch(err => append(`\n[error: ${err.message}]`)); +} + +window.issueSSL = (domainId, domain) => _sslStream({ domain }, () => loadDomainsList()); window.issueSSL = window.issueSSL; /* ── Email ──────────────────────────────────────────────────────────────── */ @@ -514,15 +555,11 @@ window.issueNewSSL = () => { ``); }); }; -window.submitIssueSSL = async () => { +window.submitIssueSSL = () => { const domain = document.getElementById('ssl-dom')?.value; - const email = document.getElementById('ssl-email')?.value; + const email = document.getElementById('ssl-email')?.value; document.querySelector('.modal-overlay')?.remove(); - Nova.loading(`Issuing SSL for ${domain}…`); - const res = await Nova.api('ssl', 'issue', { method:'POST', body:{ domain, email }}); - Nova.loadingDone(); - if (res?.success) { Nova.toast('SSL issued!','success'); loadSSLList(); } - else Nova.toast(res?.message || 'SSL issue failed','error',8000); + _sslStream({ domain, email }, () => loadSSLList()); }; window.renewCert = async (id) => { Nova.toast('Renewing…','info');