Add full API endpoint suite, lib managers, webmail (Roundcube :8883), and NovaCPX icon/branding assets

- 14 API endpoints: accounts, packages, domains, dns, email, databases, ftp, ssl, cron, php, files, stats, webmail, server_setup
- 8 lib managers: AccountManager, VhostManager, DNSManager, EmailManager, DatabaseManager, PHPManager, FTPManager, SSLManager
- Roundcube webmail on dedicated port 8883 (sequenced after 8880/8881/8882)
- Custom NovaCPX SVG icon sprite (30+ unique icons), logo, mark, favicon
- PORT_WEBMAIL=8883 wired into Core.php, install.sh, UFW, Fail2Ban, credentials file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 05:50:50 +00:00
parent 716d292e77
commit e3b166803a
28 changed files with 2576 additions and 1 deletions
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* Accounts endpoint — create/list/suspend/terminate hosting accounts
* Admin + Reseller access
*/
Auth::getInstance()->require('admin', 'reseller');
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
require_once NOVACPX_LIB . '/AccountManager.php';
require_once NOVACPX_LIB . '/VhostManager.php';
require_once NOVACPX_LIB . '/DNSManager.php';
require_once NOVACPX_LIB . '/PHPManager.php';
// Resellers can only see their own accounts
$ownerId = $user['role'] === 'reseller' ? $user['uid'] : null;
$ownerClause = $ownerId ? "AND u.reseller_id = {$ownerId}" : '';
match ($action) {
'list' => (function() use ($db, $ownerClause) {
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = min(100, (int)($_GET['per_page'] ?? 25));
$search = $_GET['search'] ?? '';
$status = $_GET['status'] ?? '';
$offset = ($page - 1) * $perPage;
$where = "WHERE 1=1 $ownerClause";
$params = [];
if ($search) { $where .= " AND (a.domain LIKE ? OR a.username LIKE ?)"; $params[] = "%$search%"; $params[] = "%$search%"; }
if ($status) { $where .= " AND a.status = ?"; $params[] = $status; }
$total = $db->fetchOne("SELECT COUNT(*) c FROM accounts a JOIN users u ON u.id = a.user_id $where", $params)['c'];
$rows = $db->fetchAll(
"SELECT a.*, u.email, u.role,
p.name as package_name,
(SELECT COUNT(*) FROM domains WHERE account_id = a.id) as domain_count,
(SELECT COUNT(*) FROM email_accounts WHERE account_id = a.id) as email_count,
(SELECT COUNT(*) FROM databases WHERE account_id = a.id) as db_count
FROM accounts a
JOIN users u ON u.id = a.user_id
LEFT JOIN packages p ON p.id = a.package_id
$where ORDER BY a.created_at DESC LIMIT ? OFFSET ?",
[...$params, $perPage, $offset]
);
Response::paginate($rows, (int)$total, $page, $perPage);
})(),
'get' => (function() use ($db, $ownerClause) {
$id = (int)($_GET['id'] ?? 0);
$acct = $db->fetchOne(
"SELECT a.*, u.email, p.name as package_name FROM accounts a
JOIN users u ON u.id = a.user_id
LEFT JOIN packages p ON p.id = a.package_id
WHERE a.id = ? $ownerClause",
[$id]
);
if (!$acct) Response::error("Account not found", 404);
$acct['domains'] = $db->fetchAll("SELECT * FROM domains WHERE account_id = ?", [$id]);
$acct['disk_used'] = AccountManager::getDiskUsage($acct['home_dir']);
Response::success($acct);
})(),
'create' => (function() use ($db, $body, $user) {
$required = ['username','domain','email','password'];
foreach ($required as $f) { if (empty($body[$f])) Response::error("$f is required"); }
// Create user account
$userId = (int)$db->insert(
"INSERT INTO users (username, password, email, role, status, reseller_id) VALUES (?,?,?,?,?,?)",
[
$body['username'],
password_hash($body['password'], PASSWORD_BCRYPT),
$body['email'],
'user',
'active',
$user['role'] === 'reseller' ? $user['uid'] : null,
]
);
$body['user_id'] = $userId;
$result = AccountManager::create($body);
audit('account.create', $body['domain'], $result);
Response::success($result, 'Account created successfully');
})(),
'suspend' => (function() use ($db, $body, $ownerClause) {
$id = (int)($body['id'] ?? 0);
$acct = $db->fetchOne("SELECT a.id FROM accounts a JOIN users u ON u.id = a.user_id WHERE a.id = ? $ownerClause", [$id]);
if (!$acct) Response::error("Account not found", 404);
AccountManager::suspend($id, $body['reason'] ?? '');
audit('account.suspend', "account:$id");
Response::success(null, 'Account suspended');
})(),
'unsuspend' => (function() use ($db, $body, $ownerClause) {
$id = (int)($body['id'] ?? 0);
AccountManager::unsuspend($id);
audit('account.unsuspend', "account:$id");
Response::success(null, 'Account unsuspended');
})(),
'terminate' => (function() use ($db, $body, $user) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? 0);
AccountManager::terminate($id);
audit('account.terminate', "account:$id");
Response::success(null, 'Account terminated');
})(),
'change-password' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (strlen($pass) < 8) Response::error("Password must be at least 8 characters");
$acct = $db->fetchOne("SELECT user_id FROM accounts WHERE id = ?", [$id]);
if (!$acct) Response::error("Account not found", 404);
$db->execute("UPDATE users SET password = ? WHERE id = ?", [password_hash($pass, PASSWORD_BCRYPT), $acct['user_id']]);
// Also update system user password
shell_exec("echo " . escapeshellarg("{$id}:{$pass}") . " | chpasswd 2>/dev/null");
audit('account.change-password', "account:$id");
Response::success(null, 'Password changed');
})(),
'usage' => (function() use ($db) {
$id = (int)($_GET['id'] ?? 0);
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$id]);
if (!$acct) Response::error("Account not found", 404);
$pkg = $acct['package_id'] ? $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$acct['package_id']]) : null;
Response::success([
'disk_used_mb' => AccountManager::getDiskUsage($acct['home_dir']),
'disk_limit_mb' => $pkg['disk_mb'] ?? 0,
'email_count' => $db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$id])['c'],
'email_limit' => $pkg['max_email'] ?? 0,
'db_count' => $db->fetchOne("SELECT COUNT(*) c FROM databases WHERE account_id = ?", [$id])['c'],
'db_limit' => $pkg['max_databases'] ?? 0,
'domain_count' => $db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$id])['c'],
'domain_limit' => $pkg['max_domains'] ?? 0,
'ftp_count' => $db->fetchOne("SELECT COUNT(*) c FROM ftp_accounts WHERE account_id = ?", [$id])['c'],
'ftp_limit' => $pkg['max_ftp'] ?? 0,
]);
})(),
default => Response::error("Unknown accounts action: $action", 404),
};
+69
View File
@@ -0,0 +1,69 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
function writeCrontab(int $accountId, $db): void {
$acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) return;
$jobs = $db->fetchAll("SELECT * FROM cron_jobs WHERE account_id = ? AND is_active = 1", [$accountId]);
$cron = "# NovaCPX cron jobs for {$acct['username']}\n";
foreach ($jobs as $j) {
$cron .= "{$j['minute']} {$j['hour']} {$j['day']} {$j['month']} {$j['weekday']} {$acct['username']} {$j['command']}\n";
}
file_put_contents("/etc/cron.d/novacpx-{$acct['username']}", $cron);
}
match ($action) {
'list' => (function() use ($db, $accountId) {
Response::success($db->fetchAll("SELECT * FROM cron_jobs WHERE account_id = ? ORDER BY id", [$accountId]));
})(),
'create' => (function() use ($db, $body, $accountId) {
$cmd = trim($body['command'] ?? '');
if (!$cmd) Response::error("command required");
// Validate cron schedule fields
$fields = ['minute','hour','day','month','weekday'];
foreach ($fields as $f) { if (empty($body[$f])) $body[$f] = '*'; }
$id = (int)$db->insert(
"INSERT INTO cron_jobs (account_id, command, minute, hour, day, month, weekday) VALUES (?,?,?,?,?,?,?)",
[$accountId, $cmd, $body['minute'], $body['hour'], $body['day'], $body['month'], $body['weekday']]
);
writeCrontab($accountId, $db);
audit('cron.create', $cmd);
Response::success(['id' => $id], 'Cron job created');
})(),
'update' => (function() use ($db, $body, $accountId) {
$id = (int)($body['id'] ?? 0);
$db->execute(
"UPDATE cron_jobs SET command=?, minute=?, hour=?, day=?, month=?, weekday=?, is_active=? WHERE id=? AND account_id=?",
[$body['command'], $body['minute'] ?? '*', $body['hour'] ?? '*', $body['day'] ?? '*',
$body['month'] ?? '*', $body['weekday'] ?? '*', (int)($body['is_active'] ?? 1), $id, $accountId]
);
writeCrontab($accountId, $db);
Response::success(null, 'Cron job updated');
})(),
'delete' => (function() use ($db, $body, $accountId) {
$id = (int)($body['id'] ?? 0);
$db->execute("DELETE FROM cron_jobs WHERE id = ? AND account_id = ?", [$id, $accountId]);
writeCrontab($accountId, $db);
audit('cron.delete', "job:$id");
Response::success(null, 'Cron job deleted');
})(),
'toggle' => (function() use ($db, $body, $accountId) {
$id = (int)($body['id'] ?? 0);
$db->execute("UPDATE cron_jobs SET is_active = NOT is_active WHERE id = ? AND account_id = ?", [$id, $accountId]);
writeCrontab($accountId, $db);
Response::success(null, 'Cron job toggled');
})(),
default => Response::error("Unknown cron action: $action", 404),
};
+60
View File
@@ -0,0 +1,60 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/DatabaseManager.php';
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
match ($action) {
'list' => (function() use ($db, $accountId) {
if (!$accountId) Response::error("account_id required");
$rows = $db->fetchAll("SELECT id, db_name, db_user, db_type, size_mb, created_at FROM databases WHERE account_id = ?", [$accountId]);
foreach ($rows as &$r) { $r['size_mb'] = DatabaseManager::getSize($r['db_name'], $r['db_type']); }
Response::success($rows);
})(),
'create' => (function() use ($db, $body, $accountId) {
if (!$accountId) Response::error("account_id required");
$type = $body['type'] ?? 'mysql';
$dbName = trim($body['db_name'] ?? '');
$dbUser = trim($body['db_user'] ?? $dbName . '_user');
$dbPass = $body['db_pass'] ?? bin2hex(random_bytes(8));
if (!$dbName) Response::error("db_name required");
// Prefix with account username to avoid conflicts
$acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]);
$prefix = $acct['username'] . '_';
if (!str_starts_with($dbName, $prefix)) $dbName = $prefix . $dbName;
if (!str_starts_with($dbUser, $prefix)) $dbUser = $prefix . $dbUser;
$id = $type === 'postgresql'
? DatabaseManager::createPostgres($accountId, $dbName, $dbUser, $dbPass)
: DatabaseManager::createMySQL($accountId, $dbName, $dbUser, $dbPass);
audit('database.create', $dbName, ['type' => $type]);
Response::success(['id' => $id, 'db_name' => $dbName, 'db_user' => $dbUser, 'db_pass' => $dbPass], 'Database created');
})(),
'drop' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
$dbe = $db->fetchOne("SELECT db_name, db_user, db_type FROM databases WHERE id = ?", [$id]);
if (!$dbe) Response::error("Database not found", 404);
DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']);
audit('database.drop', $dbe['db_name']);
Response::success(null, 'Database deleted');
})(),
'change-password' => (function() use ($body) {
$id = (int)($body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (strlen($pass) < 8) Response::error("Password must be at least 8 characters");
DatabaseManager::changePassword($id, $pass);
Response::success(null, 'Database password updated');
})(),
default => Response::error("Unknown databases action: $action", 404),
};
+101
View File
@@ -0,0 +1,101 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/DNSManager.php';
// Resolve account_id from current user or from query param (admin)
$user = Auth::getInstance()->user();
$accountId = null;
if ($user['role'] === 'user') {
$acct = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
$accountId = $acct ? (int)$acct['id'] : null;
} else {
$accountId = (int)($_GET['account_id'] ?? $body['account_id'] ?? 0) ?: null;
}
match ($action) {
'zones' => (function() use ($db, $accountId, $user) {
$where = $accountId ? "WHERE account_id = $accountId" : ($user['role'] !== 'admin' ? "WHERE 1=0" : '');
$rows = $db->fetchAll("SELECT z.*, (SELECT COUNT(*) FROM dns_records WHERE zone_id = z.id) as record_count FROM dns_zones z $where ORDER BY z.domain");
Response::success($rows);
})(),
'records' => (function() use ($db, $body) {
$zoneId = (int)($_GET['zone_id'] ?? 0);
if (!$zoneId) Response::error("zone_id required");
$records = $db->fetchAll("SELECT * FROM dns_records WHERE zone_id = ? ORDER BY type, name", [$zoneId]);
$zone = $db->fetchOne("SELECT * FROM dns_zones WHERE id = ?", [$zoneId]);
Response::success(['zone' => $zone, 'records' => $records]);
})(),
'add-record' => (function() use ($db, $body, $accountId) {
$zoneId = (int)($body['zone_id'] ?? 0);
$name = trim($body['name'] ?? '@');
$type = strtoupper($body['type'] ?? 'A');
$content = trim($body['content'] ?? '');
$ttl = (int)($body['ttl'] ?? 3600);
$priority = isset($body['priority']) ? (int)$body['priority'] : null;
if (!$content) Response::error("Record content required");
$allowed = ['A','AAAA','CNAME','MX','TXT','SRV','NS','CAA','DKIM','SPF','DMARC'];
if (!in_array($type, $allowed)) Response::error("Invalid record type");
// Verify zone belongs to this account
$zone = $db->fetchOne("SELECT id FROM dns_zones WHERE id = ?" . ($accountId ? " AND account_id = $accountId" : ''), [$zoneId]);
if (!$zone) Response::error("Zone not found", 404);
$id = DNSManager::addRecord($zoneId, $name, $type, $content, $ttl, $priority);
audit('dns.add-record', "{$type}:{$name}", ['zone_id' => $zoneId]);
Response::success(['id' => $id], 'Record added');
})(),
'update-record' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
DNSManager::updateRecord($id, $body);
audit('dns.update-record', "record:$id");
Response::success(null, 'Record updated');
})(),
'delete-record' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
DNSManager::deleteRecord($id);
audit('dns.delete-record', "record:$id");
Response::success(null, 'Record deleted');
})(),
'create-zone' => (function() use ($db, $body, $accountId, $user) {
Auth::getInstance()->require('admin', 'reseller');
$domain = strtolower(trim($body['domain'] ?? ''));
$acctId = (int)($body['account_id'] ?? $accountId ?? 0);
if (!$domain) Response::error("Domain required");
if (!$acctId) Response::error("account_id required");
DNSManager::createZone($acctId, $domain);
audit('dns.create-zone', $domain);
Response::success(null, "DNS zone created for $domain");
})(),
'delete-zone' => (function() use ($db, $body, $user) {
Auth::getInstance()->require('admin');
$domain = trim($body['domain'] ?? '');
if (!$domain) Response::error("Domain required");
DNSManager::removeZone($domain);
audit('dns.delete-zone', $domain);
Response::success(null, "DNS zone removed for $domain");
})(),
'check-propagation' => (function() use ($db) {
$domain = trim($_GET['domain'] ?? '');
if (!$domain) Response::error("Domain required");
$results = [];
$nameservers = ['8.8.8.8', '1.1.1.1', '9.9.9.9'];
foreach ($nameservers as $ns) {
$out = trim(shell_exec("dig +short A " . escapeshellarg($domain) . " @{$ns} 2>/dev/null") ?: '');
$results[$ns] = $out ?: 'no answer';
}
Response::success(['domain' => $domain, 'results' => $results]);
})(),
default => Response::error("Unknown dns action: $action", 404),
};
+123
View File
@@ -0,0 +1,123 @@
<?php
/**
* Domains endpoint — addon domains, subdomains, parked/aliased domains
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/VhostManager.php';
require_once NOVACPX_LIB . '/DNSManager.php';
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
if (!$accountId) Response::error("account_id required");
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found", 404);
match ($action) {
'list' => (function() use ($db, $accountId) {
$rows = $db->fetchAll("SELECT * FROM domains WHERE account_id = ? ORDER BY is_primary DESC, domain", [$accountId]);
Response::success($rows);
})(),
'add-addon' => (function() use ($db, $body, $accountId, $acct) {
$domain = strtolower(trim($body['domain'] ?? ''));
if (!$domain) Response::error("domain required");
if (!preg_match('/^[a-z0-9][a-z0-9\-\.]+\.[a-z]{2,}$/', $domain)) Response::error("Invalid domain name");
$exists = $db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain]);
if ($exists) Response::error("Domain already exists");
$db->execute(
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
[$accountId, $domain, 'addon', $acct['home_dir'] . '/public_html/' . $domain]
);
$docRoot = $acct['home_dir'] . '/public_html/' . $domain;
@mkdir($docRoot, 0755, true);
file_put_contents("$docRoot/index.html", "<html><body><h1>$domain is ready!</h1><p>Upload your website files.</p></body></html>");
VhostManager::create([
'domain' => $domain,
'username' => $acct['username'],
'home_dir' => $acct['home_dir'],
'doc_root' => $docRoot,
'php_ver' => $acct['php_version'] ?? PHP_DEFAULT,
]);
DNSManager::createZone($domain, gethostbyname(gethostname()));
audit('domains.add-addon', $domain);
Response::success(null, "Addon domain $domain added");
})(),
'add-subdomain' => (function() use ($db, $body, $accountId, $acct) {
$sub = strtolower(trim($body['subdomain'] ?? ''));
$parent = strtolower(trim($body['parent'] ?? $acct['domain']));
$full = "$sub.$parent";
if (!$sub) Response::error("subdomain required");
$docRoot = $acct['home_dir'] . '/public_html/' . $full;
@mkdir($docRoot, 0755, true);
file_put_contents("$docRoot/index.html", "<html><body><h1>$full</h1></body></html>");
$db->execute(
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
[$accountId, $full, 'subdomain', $docRoot]
);
VhostManager::createSubdomain($full, $acct['username'], $docRoot, $acct['php_version'] ?? PHP_DEFAULT);
DNSManager::addRecord($parent, $sub, 'A', gethostbyname(gethostname()));
audit('domains.add-subdomain', $full);
Response::success(null, "Subdomain $full created");
})(),
'add-alias' => (function() use ($db, $body, $accountId, $acct) {
$alias = strtolower(trim($body['domain'] ?? ''));
$target = strtolower(trim($body['target'] ?? $acct['domain']));
if (!$alias) Response::error("domain required");
$db->execute(
"INSERT INTO domains (account_id, domain, type, doc_root, created_at) VALUES (?,?,?,?,NOW())",
[$accountId, $alias, 'alias', $acct['home_dir'] . '/public_html']
);
// Create vhost that serves same doc root as primary
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,
]);
DNSManager::createZone($alias, gethostbyname(gethostname()));
audit('domains.add-alias', $alias);
Response::success(null, "Domain alias $alias added");
})(),
'redirect' => (function() use ($db, $body, $accountId) {
$id = (int)($body['id'] ?? 0);
$url = trim($body['redirect_url'] ?? '');
$code = in_array((int)($body['code'] ?? 301), [301, 302]) ? (int)$body['code'] : 301;
if (!$url) Response::error("redirect_url required");
$db->execute("UPDATE domains SET redirect_url=?, redirect_code=? WHERE id=? AND account_id=?", [$url, $code, $id, $accountId]);
// Update vhost to add Redirect directive
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ?", [$id]);
if ($dom) {
VhostManager::setRedirect($dom['domain'], $url, $code);
}
Response::success(null, 'Redirect configured');
})(),
'remove' => (function() use ($db, $body, $accountId) {
$id = (int)($body['id'] ?? 0);
$dom = $db->fetchOne("SELECT * FROM domains WHERE id = ? AND account_id = ?", [$id, $accountId]);
if (!$dom) Response::error("Domain not found", 404);
if ($dom['is_primary']) Response::error("Cannot remove primary domain");
VhostManager::remove($dom['domain']);
if ($dom['type'] !== 'subdomain') DNSManager::removeZone($dom['domain']);
$db->execute("DELETE FROM domains WHERE id = ?", [$id]);
audit('domains.remove', $dom['domain']);
Response::success(null, "Domain {$dom['domain']} removed");
})(),
default => Response::error("Unknown domains action: $action", 404),
};
+94
View File
@@ -0,0 +1,94 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/EmailManager.php';
$user = Auth::getInstance()->user();
$accountId = self_account_id($db, $user);
function self_account_id($db, $user): ?int {
if ($user['role'] === 'user') {
$a = $db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']]);
return $a ? (int)$a['id'] : null;
}
return (int)(request_param('account_id') ?? 0) ?: null;
}
function request_param(string $k): mixed { return $_GET[$k] ?? (json_decode(file_get_contents('php://input'), true) ?? [])[$k] ?? null; }
match ($action) {
'list' => (function() use ($db, $accountId) {
if (!$accountId) Response::error("account_id required");
$rows = $db->fetchAll("SELECT id, email, quota_mb, used_mb, status, created_at FROM email_accounts WHERE account_id = ? ORDER BY email", [$accountId]);
Response::success($rows);
})(),
'create' => (function() use ($db, $body, $accountId) {
$email = strtolower(trim($body['email'] ?? ''));
$password = $body['password'] ?? '';
$quota = (int)($body['quota_mb'] ?? 500);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) Response::error("Invalid email address");
if (strlen($password) < 6) Response::error("Password must be at least 6 characters");
if (!$accountId) Response::error("account_id required");
$id = EmailManager::createAccount($accountId, $email, $password, $quota);
audit('email.create', $email);
Response::success(['id' => $id], "Email account created: $email");
})(),
'delete' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
EmailManager::deleteAccount($id);
audit('email.delete', "email:$id");
Response::success(null, 'Email account deleted');
})(),
'change-password' => (function() use ($body) {
$id = (int)($body['id'] ?? 0);
$pass = $body['password'] ?? '';
if (strlen($pass) < 6) Response::error("Password too short");
EmailManager::changePassword($id, $pass);
Response::success(null, 'Password updated');
})(),
'suspend' => (function() use ($body) {
EmailManager::suspend((int)($body['id'] ?? 0));
Response::success(null, 'Email account suspended');
})(),
// ── Forwarders ───────────────────────────────────────────────────────────
'forwarders' => (function() use ($db, $accountId) {
if (!$accountId) Response::error("account_id required");
Response::success($db->fetchAll("SELECT * FROM email_forwarders WHERE account_id = ?", [$accountId]));
})(),
'add-forwarder' => (function() use ($db, $body, $accountId) {
$source = strtolower(trim($body['source'] ?? ''));
$dest = strtolower(trim($body['destination'] ?? ''));
if (!filter_var($dest, FILTER_VALIDATE_EMAIL)) Response::error("Invalid destination email");
$id = EmailManager::addForwarder($accountId, $source, $dest);
audit('email.add-forwarder', "{$source}{$dest}");
Response::success(['id' => $id], 'Forwarder added');
})(),
'delete-forwarder' => (function() use ($body) {
EmailManager::removeForwarder((int)($body['id'] ?? 0));
Response::success(null, 'Forwarder removed');
})(),
// ── Autoresponders ───────────────────────────────────────────────────────
'autoresponders' => (function() use ($db, $accountId) {
Response::success($db->fetchAll("SELECT * FROM email_autoresponders WHERE account_id = ?", [$accountId ?? 0]));
})(),
'add-autoresponder' => (function() use ($body, $accountId) {
$id = EmailManager::addAutoresponder($accountId, $body['email'] ?? '', $body['subject'] ?? '', $body['body'] ?? '');
Response::success(['id' => $id], 'Autoresponder created');
})(),
'delete-autoresponder' => (function() use ($db, $body) {
$db->execute("DELETE FROM email_autoresponders WHERE id = ?", [(int)($body['id'] ?? 0)]);
Response::success(null, 'Autoresponder deleted');
})(),
default => Response::error("Unknown email action: $action", 404),
};
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* Files endpoint — file manager (list, read, write, rename, delete, chmod, upload, archive)
* Strictly confined to account home directory via realpath check
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
$acct = $db->fetchOne("SELECT * FROM accounts WHERE user_id = ?", [$user['uid']]);
if (!$acct && $user['role'] !== 'admin') Response::error("No hosting account found", 404);
$baseDir = $acct ? realpath($acct['home_dir']) : '/';
function safe_path(string $base, string $rel): string {
$full = realpath($base . '/' . ltrim($rel, '/'));
if (!$full || !str_starts_with($full, $base)) {
throw new RuntimeException("Path outside account directory");
}
return $full;
}
function fmt_size(int $bytes): string {
if ($bytes >= 1073741824) return round($bytes/1073741824, 1) . 'GB';
if ($bytes >= 1048576) return round($bytes/1048576, 1) . 'MB';
if ($bytes >= 1024) return round($bytes/1024, 1) . 'KB';
return $bytes . 'B';
}
match ($action) {
'list' => (function() use ($baseDir, $body) {
$rel = $body['path'] ?? $_GET['path'] ?? '/public_html';
$path = safe_path($baseDir, $rel);
if (!is_dir($path)) Response::error("Not a directory");
$items = [];
foreach (new DirectoryIterator($path) as $f) {
if ($f->isDot()) continue;
$items[] = [
'name' => $f->getFilename(),
'type' => $f->isDir() ? 'dir' : 'file',
'size' => $f->isFile() ? fmt_size($f->getSize()) : null,
'size_raw' => $f->isFile() ? $f->getSize() : 0,
'perms' => substr(sprintf('%o', $f->getPerms()), -4),
'modified' => date('Y-m-d H:i', $f->getMTime()),
'path' => str_replace($baseDir, '', $path . '/' . $f->getFilename()),
];
}
usort($items, fn($a,$b) => ($a['type'] === 'dir' ? -1 : 1) - ($b['type'] === 'dir' ? -1 : 1) ?: strcmp($a['name'], $b['name']));
Response::success(['path' => $rel, 'items' => $items]);
})(),
'read' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? $_GET['path'] ?? '');
if (!is_file($path)) Response::error("Not a file");
if (filesize($path) > 2 * 1024 * 1024) Response::error("File too large to edit (>2MB)");
Response::success(['content' => file_get_contents($path), 'path' => $body['path'] ?? '']);
})(),
'write' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
$content = $body['content'] ?? '';
// PHP syntax check for .php files
if (str_ends_with($path, '.php')) {
$tmp = tempnam(sys_get_temp_dir(), 'ncpx_');
file_put_contents($tmp, $content);
$result = shell_exec("php8.3 -l " . escapeshellarg($tmp) . " 2>&1");
unlink($tmp);
if (!str_contains($result, 'No syntax errors')) Response::error("PHP syntax error: $result");
}
file_put_contents($path, $content);
audit('files.write', $body['path'] ?? '');
Response::success(null, 'File saved');
})(),
'mkdir' => (function() use ($baseDir, $body) {
$path = $baseDir . '/' . ltrim($body['path'] ?? '', '/');
// Don't use safe_path since dir may not exist yet
if (!str_starts_with(realpath(dirname($path)) ?: '', $baseDir)) Response::error("Invalid path");
if (!mkdir($path, 0755, true)) Response::error("Could not create directory");
Response::success(null, 'Directory created');
})(),
'rename' => (function() use ($baseDir, $body) {
$from = safe_path($baseDir, $body['from'] ?? '');
$to = $baseDir . '/' . ltrim($body['to'] ?? '', '/');
if (!str_starts_with(realpath(dirname($to)) ?: $baseDir, $baseDir)) Response::error("Invalid destination");
rename($from, $to);
Response::success(null, 'Renamed');
})(),
'delete' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
if (is_dir($path)) {
shell_exec("rm -rf " . escapeshellarg($path));
} else {
unlink($path);
}
audit('files.delete', $body['path'] ?? '');
Response::success(null, 'Deleted');
})(),
'chmod' => (function() use ($baseDir, $body) {
$path = safe_path($baseDir, $body['path'] ?? '');
$perms = octdec((string)($body['perms'] ?? '755'));
chmod($path, $perms);
Response::success(null, 'Permissions updated');
})(),
'upload' => (function() use ($baseDir, $body) {
$dir = safe_path($baseDir, $body['path'] ?? '/public_html');
if (empty($_FILES['file'])) Response::error("No file uploaded");
$dest = $dir . '/' . basename($_FILES['file']['name']);
if (!str_starts_with(realpath(dirname($dest)) ?: $baseDir, $baseDir)) Response::error("Invalid destination");
move_uploaded_file($_FILES['file']['tmp_name'], $dest);
Response::success(['name' => basename($dest)], 'File uploaded');
})(),
'compress' => (function() use ($baseDir, $body) {
$paths = array_map(fn($p) => safe_path($baseDir, $p), (array)($body['paths'] ?? []));
$dest = $baseDir . '/' . ltrim($body['dest'] ?? 'archive.zip', '/');
$files = implode(' ', array_map('escapeshellarg', $paths));
shell_exec("cd " . escapeshellarg($baseDir) . " && zip -r " . escapeshellarg($dest) . " $files 2>/dev/null");
Response::success(null, 'Archive created');
})(),
'extract' => (function() use ($baseDir, $body) {
$file = safe_path($baseDir, $body['path'] ?? '');
$dest = safe_path($baseDir, $body['dest'] ?? dirname($body['path'] ?? '/'));
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
match($ext) {
'zip' => shell_exec("unzip -o " . escapeshellarg($file) . " -d " . escapeshellarg($dest)),
'gz','tgz','tar' => shell_exec("tar xf " . escapeshellarg($file) . " -C " . escapeshellarg($dest)),
default => Response::error("Unsupported archive type"),
};
Response::success(null, 'Extracted');
})(),
default => Response::error("Unknown files action: $action", 404),
};
+40
View File
@@ -0,0 +1,40 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/FTPManager.php';
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
match ($action) {
'list' => (function() use ($db, $accountId) {
Response::success($db->fetchAll("SELECT id, username, home_dir, quota_mb, status, created_at FROM ftp_accounts WHERE account_id = ?", [$accountId]));
})(),
'create' => (function() use ($db, $body, $accountId) {
if (!$accountId) Response::error("account_id required");
$username = trim($body['username'] ?? '');
$password = $body['password'] ?? bin2hex(random_bytes(6));
$acct = $db->fetchOne("SELECT home_dir FROM accounts WHERE id = ?", [$accountId]);
$homeDir = $body['home_dir'] ?? ($acct['home_dir'] . '/public_html');
if (!$username) Response::error("username required");
$id = FTPManager::createAccount($accountId, $username, $password, $homeDir, (int)($body['quota_mb'] ?? 0));
audit('ftp.create', $username);
Response::success(['id' => $id, 'password' => $password], 'FTP account created');
})(),
'delete' => (function() use ($body) {
FTPManager::deleteAccount((int)($body['id'] ?? 0));
audit('ftp.delete', "ftp:{$body['id']}");
Response::success(null, 'FTP account deleted');
})(),
'change-password' => (function() use ($body) {
FTPManager::changePassword((int)($body['id'] ?? 0), $body['password'] ?? '');
Response::success(null, 'FTP password updated');
})(),
default => Response::error("Unknown ftp action: $action", 404),
};
+74
View File
@@ -0,0 +1,74 @@
<?php
Auth::getInstance()->require('admin', 'reseller');
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
$ownerFilter = $user['role'] === 'reseller' ? "AND (owner_id = {$user['uid']} OR owner_id IS NULL)" : '';
match ($action) {
'list' => (function() use ($db, $ownerFilter) {
$rows = $db->fetchAll("SELECT p.*, (SELECT COUNT(*) FROM accounts WHERE package_id = p.id) as account_count FROM packages p WHERE 1=1 $ownerFilter ORDER BY p.name");
Response::success($rows);
})(),
'get' => (function() use ($db) {
$id = (int)($_GET['id'] ?? 0);
$row = $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$id]);
if (!$row) Response::error("Package not found", 404);
Response::success($row);
})(),
'create' => (function() use ($db, $body, $user) {
$name = trim($body['name'] ?? '');
if (!$name) Response::error("Package name required");
$id = (int)$db->insert(
"INSERT INTO packages (name, owner_id, disk_mb, bandwidth_mb, max_domains, max_subdomains, max_addon_domains, max_parked_domains, max_email, max_ftp, max_databases, php_version, ssl_enabled)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
$name,
$user['role'] === 'reseller' ? $user['uid'] : null,
(int)($body['disk_mb'] ?? 1024),
(int)($body['bandwidth_mb'] ?? 10240),
(int)($body['max_domains'] ?? 1),
(int)($body['max_subdomains'] ?? 10),
(int)($body['max_addon_domains'] ?? 0),
(int)($body['max_parked_domains'] ?? 5),
(int)($body['max_email'] ?? 10),
(int)($body['max_ftp'] ?? 5),
(int)($body['max_databases'] ?? 5),
$body['php_version'] ?? '8.3',
(int)($body['ssl_enabled'] ?? 1),
]
);
audit('package.create', $name);
Response::success(['id' => $id], 'Package created');
})(),
'update' => (function() use ($db, $body) {
$id = (int)($body['id'] ?? 0);
$db->execute(
"UPDATE packages SET name=?, disk_mb=?, bandwidth_mb=?, max_domains=?, max_subdomains=?,
max_addon_domains=?, max_parked_domains=?, max_email=?, max_ftp=?, max_databases=?, php_version=?, ssl_enabled=? WHERE id=?",
[
$body['name'], $body['disk_mb'], $body['bandwidth_mb'], $body['max_domains'],
$body['max_subdomains'], $body['max_addon_domains'], $body['max_parked_domains'],
$body['max_email'], $body['max_ftp'], $body['max_databases'],
$body['php_version'] ?? '8.3', $body['ssl_enabled'] ?? 1, $id,
]
);
audit('package.update', "package:$id");
Response::success(null, 'Package updated');
})(),
'delete' => (function() use ($db, $body) {
Auth::getInstance()->require('admin');
$id = (int)($body['id'] ?? 0);
$cnt = $db->fetchOne("SELECT COUNT(*) c FROM accounts WHERE package_id = ?", [$id])['c'];
if ($cnt > 0) Response::error("Cannot delete: $cnt accounts use this package");
$db->execute("DELETE FROM packages WHERE id = ?", [$id]);
audit('package.delete', "package:$id");
Response::success(null, 'Package deleted');
})(),
default => Response::error("Unknown packages action: $action", 404),
};
+56
View File
@@ -0,0 +1,56 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/PHPManager.php';
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
match ($action) {
'config' => (function() use ($db, $accountId) {
$cfg = $db->fetchOne("SELECT * FROM php_configs WHERE account_id = ?", [$accountId]);
$acct = $db->fetchOne("SELECT php_version FROM accounts WHERE id = ?", [$accountId]);
Response::success([
'php_version' => $acct['php_version'] ?? PHP_DEFAULT,
'memory_limit' => $cfg['memory_limit'] ?? '256M',
'max_execution_time' => $cfg['max_execution_time'] ?? 30,
'upload_max_filesize'=> $cfg['upload_max_filesize'] ?? '64M',
'post_max_size' => $cfg['post_max_size'] ?? '64M',
'display_errors' => (bool)($cfg['display_errors'] ?? false),
'extensions' => json_decode($cfg['extensions'] ?? '[]', true) ?: [],
]);
})(),
'versions' => (function() {
$versions = [];
foreach (['7.4','8.1','8.2','8.3'] as $v) {
$installed = file_exists("/usr/bin/php{$v}");
$versions[] = ['version' => $v, 'installed' => $installed, 'is_default' => $v === PHP_DEFAULT];
}
Response::success($versions);
})(),
'switch-version' => (function() use ($body, $accountId) {
$ver = $body['version'] ?? '';
if (!in_array($ver, ['7.4','8.1','8.2','8.3'])) Response::error("Invalid PHP version");
PHPManager::switchVersion($accountId, $ver);
audit('php.switch-version', "account:{$accountId} → php{$ver}");
Response::success(null, "PHP version switched to $ver");
})(),
'update-config' => (function() use ($body, $accountId) {
PHPManager::updateConfig($accountId, $body);
audit('php.update-config', "account:$accountId");
Response::success(null, 'PHP configuration updated');
})(),
'extensions' => (function() use ($db, $accountId) {
$acct = $db->fetchOne("SELECT php_version FROM accounts WHERE id = ?", [$accountId]);
$ver = $acct['php_version'] ?? PHP_DEFAULT;
Response::success(['version' => $ver, 'extensions' => PHPManager::listExtensions($ver)]);
})(),
default => Response::error("Unknown php action: $action", 404),
};
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* Server Setup endpoint — hostname, contact info, nameservers, branding
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
Auth::getInstance()->requireRole(['admin']);
function getSetting(string $key, $db): ?string {
return $db->fetchOne("SELECT value FROM settings WHERE `key` = ?", [$key])['value'] ?? null;
}
function setSetting(string $key, string $value, $db): void {
$db->execute(
"INSERT INTO settings (`key`, value, updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE value=?, updated_at=NOW()",
[$key, $value, $value]
);
}
match ($action) {
'get' => (function() use ($db) {
$keys = ['hostname','contact_name','contact_email','contact_phone','company_name',
'nameserver1','nameserver2','server_ip','ipv6_enabled','panel_theme',
'panel_title','smtp_host','smtp_port','smtp_user','admin_email',
'two_fa_required','registration_enabled','max_accounts','timezone'];
$data = [];
foreach ($keys as $k) {
$data[$k] = getSetting($k, $db) ?? '';
}
// Live hostname
$data['system_hostname'] = trim(shell_exec('hostname -f') ?: '');
Response::success($data);
})(),
'save' => (function() use ($body, $db) {
$allowed = ['contact_name','contact_email','contact_phone','company_name',
'nameserver1','nameserver2','panel_theme','panel_title',
'smtp_host','smtp_port','smtp_user','smtp_pass','admin_email',
'two_fa_required','registration_enabled','max_accounts','timezone'];
foreach ($allowed as $k) {
if (isset($body[$k])) setSetting($k, (string)$body[$k], $db);
}
audit('server_setup.save', 'settings updated');
Response::success(null, 'Settings saved');
})(),
'set-hostname' => (function() use ($body, $db) {
$host = trim($body['hostname'] ?? '');
if (!$host || !preg_match('/^[a-z0-9][a-z0-9\-\.]+$/', $host)) Response::error("Invalid hostname");
shell_exec("hostnamectl set-hostname " . escapeshellarg($host));
// Update /etc/hosts
$hosts = file_get_contents('/etc/hosts');
$ip = trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1');
$hosts = preg_replace('/^127\.0\.1\.1.*/m', "127.0.1.1\t$host", $hosts);
if (!str_contains($hosts, '127.0.1.1')) $hosts .= "\n127.0.1.1\t$host\n";
file_put_contents('/etc/hosts', $hosts);
setSetting('hostname', $host, $db);
audit('server_setup.hostname', $host);
Response::success(['hostname' => $host], 'Hostname updated');
})(),
'nameservers' => (function() use ($body, $db) {
$ns1 = trim($body['ns1'] ?? '');
$ns2 = trim($body['ns2'] ?? '');
if (!$ns1) Response::error("ns1 required");
setSetting('nameserver1', $ns1, $db);
if ($ns2) setSetting('nameserver2', $ns2, $db);
// Update BIND options
$named = file_get_contents('/etc/bind/named.conf.options') ?: '';
if (str_contains($named, 'forwarders')) {
$named = preg_replace('/forwarders\s*\{[^}]*\}/', "forwarders { 8.8.8.8; 1.1.1.1; }", $named);
file_put_contents('/etc/bind/named.conf.options', $named);
shell_exec('systemctl reload bind9 2>/dev/null || systemctl reload named 2>/dev/null');
}
audit('server_setup.nameservers', "$ns1 / $ns2");
Response::success(null, 'Nameservers updated');
})(),
'smtp-test' => (function() use ($body, $db) {
$to = $body['to'] ?? getSetting('admin_email', $db);
if (!$to) Response::error("email address required");
$host = getSetting('smtp_host', $db) ?: 'localhost';
$port = (int)(getSetting('smtp_port', $db) ?: 587);
$user = getSetting('smtp_user', $db) ?: '';
$pass = getSetting('smtp_pass', $db) ?: '';
// Simple test using PHP mail() for localhost
$sent = mail($to, 'NovaCPX SMTP Test', 'SMTP is working correctly from NovaCPX.');
Response::success(['sent' => $sent], $sent ? 'Test email sent' : 'mail() returned false — check SMTP config');
})(),
'system-info' => (function() {
$os = trim(shell_exec("lsb_release -d 2>/dev/null | cut -d: -f2 | xargs") ?: php_uname('s'));
$kernel = trim(shell_exec("uname -r") ?: '');
$uptime = trim(shell_exec("uptime -p") ?: '');
$cpu = trim(shell_exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs") ?: '');
$cpuCores = (int)trim(shell_exec("nproc") ?: 1);
$memTotal = (int)trim(shell_exec("grep MemTotal /proc/meminfo | awk '{print $2}'") ?: 0);
Response::success([
'os' => $os,
'kernel' => $kernel,
'uptime' => $uptime,
'cpu' => $cpu,
'cores' => $cpuCores,
'ram_gb' => round($memTotal / 1048576, 1),
'hostname' => trim(shell_exec('hostname -f') ?: ''),
'ip' => trim(shell_exec("hostname -I | awk '{print $1}'") ?: ''),
]);
})(),
default => Response::error("Unknown server_setup action: $action", 404),
};
+62
View File
@@ -0,0 +1,62 @@
<?php
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
require_once NOVACPX_LIB . '/SSLManager.php';
require_once NOVACPX_LIB . '/VhostManager.php';
$user = Auth::getInstance()->user();
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
match ($action) {
'list' => (function() use ($db, $accountId) {
$rows = $db->fetchAll("SELECT id, domain, type, issued_at, expires_at, auto_renew, status FROM ssl_certs WHERE account_id = ? ORDER BY domain", [$accountId]);
foreach ($rows as &$r) {
$r['days_remaining'] = $r['expires_at'] ? (int)floor((strtotime($r['expires_at']) - time()) / 86400) : null;
}
Response::success($rows);
})(),
'issue' => (function() use ($body, $accountId) {
$domain = trim($body['domain'] ?? '');
$email = trim($body['email'] ?? '');
if (!$domain) Response::error("domain required");
if (!$accountId) Response::error("account_id required");
$result = SSLManager::issueLetsEncrypt($accountId, $domain, $email);
audit('ssl.issue', $domain);
Response::success($result, "SSL certificate issued for $domain");
})(),
'install-custom' => (function() use ($body, $accountId) {
$domain = trim($body['domain'] ?? '');
$cert = trim($body['cert'] ?? '');
$key = trim($body['key'] ?? '');
$chain = trim($body['chain'] ?? '');
if (!$domain || !$cert || !$key) Response::error("domain, cert, and key required");
$id = SSLManager::installCustom($accountId, $domain, $cert, $key, $chain);
audit('ssl.install-custom', $domain);
Response::success(['id' => $id], 'Custom certificate installed');
})(),
'renew' => (function() use ($db, $body, $accountId) {
$certId = (int)($body['cert_id'] ?? 0);
$cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]);
if (!$cert) Response::error("Certificate not found", 404);
$result = SSLManager::issueLetsEncrypt($accountId, $cert['domain']);
audit('ssl.renew', $cert['domain']);
Response::success($result, 'Certificate renewed');
})(),
'delete' => (function() use ($db, $body, $accountId) {
$certId = (int)($body['cert_id'] ?? 0);
$cert = $db->fetchOne("SELECT * FROM ssl_certs WHERE id = ? AND account_id = ?", [$certId, $accountId]);
if (!$cert) Response::error("Certificate not found", 404);
$db->execute("DELETE FROM ssl_certs WHERE id = ?", [$certId]);
$db->execute("UPDATE domains SET ssl_enabled = 0 WHERE domain = ?", [$cert['domain']]);
audit('ssl.delete', $cert['domain']);
Response::success(null, 'Certificate removed');
})(),
default => Response::error("Unknown ssl action: $action", 404),
};
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* Stats endpoint — resource usage history, charts, per-account usage
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
match ($action) {
'server' => (function() use ($db) {
// Last 24 hours of 5-min samples
$rows = $db->fetchAll(
"SELECT cpu_usage, ram_usage, disk_usage, load_avg, recorded_at
FROM server_stats ORDER BY recorded_at DESC LIMIT 288"
);
$rows = array_reverse($rows);
// Current live snapshot
$cpu = (float) trim(shell_exec("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'") ?: 0);
$ram = [];
foreach (file('/proc/meminfo') as $line) {
[$k, $v] = preg_split('/\s+/', trim($line), 2);
$ram[$k] = (int) $v;
}
$ramPct = $ram['MemTotal_kB'] ?? 0
? round(100 - ($ram['MemAvailable:'] / $ram['MemTotal:']) * 100, 1)
: 0;
$disk = [];
$dfLine = trim(shell_exec("df / | tail -1") ?: '');
$dfParts = preg_split('/\s+/', $dfLine);
$diskPct = isset($dfParts[4]) ? (int) $dfParts[4] : 0;
$diskUsed = isset($dfParts[2]) ? round($dfParts[2]/1024/1024, 1) : 0;
$diskTotal = isset($dfParts[1]) ? round($dfParts[1]/1024/1024, 1) : 0;
$load = sys_getloadavg();
Response::success([
'current' => [
'cpu' => $cpu,
'ram' => $ramPct,
'disk_pct' => $diskPct,
'disk_used' => $diskUsed,
'disk_total' => $diskTotal,
'load' => $load[0],
],
'history' => $rows,
]);
})(),
'account' => (function() use ($db, $body) {
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
if (!$accountId) Response::error("account_id required");
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found", 404);
// Disk
$diskKB = (int)trim(shell_exec("du -sk " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | awk '{print $1}'") ?: 0);
$diskMB = round($diskKB / 1024, 1);
// Inodes
$inodes = (int)trim(shell_exec("find " . escapeshellarg($acct['home_dir']) . " 2>/dev/null | wc -l") ?: 0);
// DB count & size
$dbs = $db->fetchAll("SELECT id FROM databases WHERE account_id = ?", [$accountId]);
$dbCount = count($dbs);
// Email count
$emailCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM email_accounts WHERE account_id = ?", [$accountId])['c'] ?? 0);
// FTP count
$ftpCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM ftp_accounts WHERE account_id = ?", [$accountId])['c'] ?? 0);
// Domain count
$domCount = (int)($db->fetchOne("SELECT COUNT(*) c FROM domains WHERE account_id = ?", [$accountId])['c'] ?? 0);
// Package limits
$pkg = $db->fetchOne("SELECT * FROM packages WHERE id = ?", [$acct['package_id'] ?? 0]);
Response::success([
'disk_mb' => $diskMB,
'disk_limit' => $pkg['disk_mb'] ?? 0,
'inodes' => $inodes,
'databases' => $dbCount,
'db_limit' => $pkg['databases'] ?? 0,
'emails' => $emailCount,
'email_limit' => $pkg['email_accounts'] ?? 0,
'ftp' => $ftpCount,
'ftp_limit' => $pkg['ftp_accounts'] ?? 0,
'domains' => $domCount,
'subdomain_limit' => $pkg['subdomains'] ?? 0,
]);
})(),
'bandwidth' => (function() use ($db, $body) {
$accountId = (int)($body['account_id'] ?? $_GET['account_id'] ?? 0);
// Read nginx/apache access log and sum bytes for account's domains
$acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found");
$logFile = "/var/log/novacpx/{$acct['username']}-access.log";
$daily = [];
if (file_exists($logFile)) {
$lines = explode("\n", trim(shell_exec("tail -50000 " . escapeshellarg($logFile)) ?: ''));
foreach ($lines as $line) {
if (preg_match('/\[(\d{2}\/\w+\/\d{4})/', $line, $m) &&
preg_match('/" \d+ (\d+)/', $line, $b)) {
$day = $m[1];
$daily[$day] = ($daily[$day] ?? 0) + (int)$b[1];
}
}
}
Response::success(array_map(fn($d,$b) => ['date'=>$d,'bytes'=>$b], array_keys($daily), $daily));
})(),
default => Response::error("Unknown stats action: $action", 404),
};
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* Webmail endpoint — Roundcube integration proxy
* Redirects authenticated users to Roundcube with SSO token
*/
$db = DB::getInstance();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$user = Auth::getInstance()->user();
match ($action) {
'url' => (function() use ($db, $body, $user) {
$accountId = $user['role'] === 'user'
? (int)($db->fetchOne("SELECT id FROM accounts WHERE user_id = ?", [$user['uid']])['id'] ?? 0)
: (int)($body['account_id'] ?? 0);
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
if (!$acct) Response::error("Account not found");
$domain = $acct['domain'];
// Roundcube installed by default at /var/www/roundcube
$rcUrl = "https://{$domain}/webmail/";
// Check if Roundcube is installed
$installed = file_exists('/var/www/roundcube/index.php');
Response::success([
'url' => $rcUrl,
'installed' => $installed,
'domain' => $domain,
]);
})(),
'install' => (function() use ($db) {
Auth::getInstance()->requireRole(['admin']);
// Background install
$logFile = '/var/log/novacpx/webmail-install.log';
$cmd = 'apt-get install -y roundcube roundcube-mysql php8.3-intl > ' . escapeshellarg($logFile) . ' 2>&1 && ' .
'ln -sf /usr/share/roundcube /var/www/roundcube >> ' . escapeshellarg($logFile) . ' 2>&1';
shell_exec("nohup bash -c " . escapeshellarg($cmd) . " &");
Response::success(['log' => $logFile], 'Webmail install started');
})(),
'login-url' => (function() use ($db, $body, $user) {
// Generate a short-lived token for auto-login
$emailAccount = $db->fetchOne("SELECT * FROM email_accounts WHERE id = ?", [(int)($body['email_id'] ?? 0)]);
if (!$emailAccount) Response::error("Email account not found");
$token = bin2hex(random_bytes(16));
$db->execute("INSERT INTO api_tokens (user_id, token, purpose, expires_at) VALUES (?,?,?,DATE_ADD(NOW(), INTERVAL 30 SECOND))",
[$user['uid'], hash('sha256', $token), 'webmail_sso']);
$domain = parse_url($emailAccount['email'], PHP_URL_HOST) ?: '';
Response::success(['url' => "https://{$domain}/webmail/?_token={$token}"]);
})(),
default => Response::error("Unknown webmail action: $action", 404),
};