mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user