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:
+79
@@ -18,6 +18,7 @@ PHP_DEFAULT="8.3"
|
||||
PORT_USER=8880 # End-user panel
|
||||
PORT_RESELLER=8881 # Reseller panel
|
||||
PORT_ADMIN=8882 # Admin / datacenter panel
|
||||
PORT_WEBMAIL=8883 # Roundcube webmail
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
@@ -114,6 +115,7 @@ NovaCPX Installation Credentials — $(date)
|
||||
User Panel: https://$(hostname -I | awk '{print $1}'):${PORT_USER}
|
||||
Reseller Panel: https://$(hostname -I | awk '{print $1}'):${PORT_RESELLER}
|
||||
Admin Panel: https://$(hostname -I | awk '{print $1}'):${PORT_ADMIN}
|
||||
Webmail: https://$(hostname -I | awk '{print $1}'):${PORT_WEBMAIL}
|
||||
Admin User: admin
|
||||
Admin Pass: $ADMIN_PASS
|
||||
DB Name: $DB_NAME
|
||||
@@ -376,6 +378,74 @@ log "SSL certificate generated"
|
||||
apt-get install -y -qq certbot >> "$LOG" 2>&1
|
||||
log "Certbot installed for Let's Encrypt SSL"
|
||||
|
||||
# ── Roundcube Webmail ─────────────────────────────────────────────────────────
|
||||
step "Installing Roundcube Webmail (port ${PORT_WEBMAIL})"
|
||||
apt-get install -y -qq roundcube roundcube-mysql php8.3-intl php8.3-ldap >> "$LOG" 2>&1
|
||||
RC_ROOT="/usr/share/roundcube"
|
||||
mkdir -p /etc/novacpx/roundcube
|
||||
|
||||
# Roundcube config
|
||||
RC_DB_PASS=$(openssl rand -base64 16 | tr -dc 'A-Za-z0-9' | head -c 16)
|
||||
mysql -e "CREATE DATABASE IF NOT EXISTS roundcube CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG" 2>&1
|
||||
mysql -e "CREATE USER IF NOT EXISTS 'roundcube'@'localhost' IDENTIFIED BY '${RC_DB_PASS}';" >> "$LOG" 2>&1
|
||||
mysql -e "GRANT ALL PRIVILEGES ON roundcube.* TO 'roundcube'@'localhost';" >> "$LOG" 2>&1
|
||||
mysql roundcube < /usr/share/dbconfig-common/data/roundcube/install/mysql 2>/dev/null || true
|
||||
|
||||
cat > /etc/roundcube/config.inc.php <<RCCONF
|
||||
<?php
|
||||
\$config['db_dsnw'] = 'mysql://roundcube:${RC_DB_PASS}@localhost/roundcube';
|
||||
\$config['default_host'] = 'localhost';
|
||||
\$config['default_port'] = 143;
|
||||
\$config['smtp_server'] = 'localhost';
|
||||
\$config['smtp_port'] = 587;
|
||||
\$config['des_key'] = '$(openssl rand -base64 24 | head -c 24)';
|
||||
\$config['plugins'] = ['archive','attachment_reminder','emoticons','markasjunk','newmail_notifier','zipdownload'];
|
||||
\$config['skin'] = 'elastic';
|
||||
\$config['session_lifetime'] = 60;
|
||||
\$config['product_name'] = 'NovaCPX Webmail';
|
||||
RCCONF
|
||||
|
||||
# Webmail vhost on port 8883
|
||||
if [[ "$WEB_SERVER" == "nginx" ]]; then
|
||||
cat >> "$PANEL_WEB_CONF" <<WMNGX
|
||||
|
||||
# ── Webmail (8883) ────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen ${PORT_WEBMAIL} ssl http2;
|
||||
server_name _;
|
||||
root ${RC_ROOT};
|
||||
index index.php;
|
||||
ssl_certificate /etc/novacpx/ssl/novacpx.crt;
|
||||
ssl_certificate_key /etc/novacpx/ssl/novacpx.key;
|
||||
location / { try_files \$uri \$uri/ /index.php; }
|
||||
location ~ \.php$ { fastcgi_pass unix:/run/php/php8.3-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; }
|
||||
location ~ /\.(ht|git) { deny all; }
|
||||
}
|
||||
WMNGX
|
||||
else
|
||||
cat >> "$PANEL_WEB_CONF" <<WMAP
|
||||
|
||||
# ── Webmail (8883) ────────────────────────────────────────────────────────────
|
||||
<VirtualHost *:${PORT_WEBMAIL}>
|
||||
DocumentRoot ${RC_ROOT}
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/novacpx/ssl/novacpx.crt
|
||||
SSLCertificateKeyFile /etc/novacpx/ssl/novacpx.key
|
||||
<Directory ${RC_ROOT}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
<FilesMatch "\.php$">
|
||||
SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost/"
|
||||
</FilesMatch>
|
||||
Header always set X-NovaCPX-Portal "webmail"
|
||||
</VirtualHost>
|
||||
WMAP
|
||||
fi
|
||||
|
||||
log "Roundcube webmail installed on port ${PORT_WEBMAIL}"
|
||||
|
||||
# ── Panel installation ────────────────────────────────────────────────────────
|
||||
step "Installing NovaCPX Panel"
|
||||
mkdir -p "$WEB_ROOT" "$PANEL_DIR"
|
||||
@@ -402,6 +472,7 @@ secret = ${SECRET_KEY}
|
||||
port_user = ${PORT_USER}
|
||||
port_reseller = ${PORT_RESELLER}
|
||||
port_admin = ${PORT_ADMIN}
|
||||
port_webmail = ${PORT_WEBMAIL}
|
||||
webroot = ${WEB_ROOT}
|
||||
version = ${NOVACPX_VERSION}
|
||||
|
||||
@@ -435,6 +506,7 @@ ufw allow 443/tcp >> "$LOG" 2>&1 # HTTPS
|
||||
ufw allow ${PORT_USER}/tcp >> "$LOG" 2>&1 # NovaCPX user panel
|
||||
ufw allow ${PORT_RESELLER}/tcp >> "$LOG" 2>&1 # NovaCPX reseller panel
|
||||
ufw allow ${PORT_ADMIN}/tcp >> "$LOG" 2>&1 # NovaCPX admin panel
|
||||
ufw allow ${PORT_WEBMAIL}/tcp >> "$LOG" 2>&1 # Roundcube webmail
|
||||
ufw allow 21/tcp >> "$LOG" 2>&1 # FTP
|
||||
ufw allow 20/tcp >> "$LOG" 2>&1 # FTP data
|
||||
ufw allow 25/tcp >> "$LOG" 2>&1 # SMTP
|
||||
@@ -477,6 +549,12 @@ enabled = true
|
||||
port = ${PORT_ADMIN}
|
||||
logpath = /var/log/novacpx/access.log
|
||||
maxretry = 5
|
||||
|
||||
[novacpx-webmail]
|
||||
enabled = true
|
||||
port = ${PORT_WEBMAIL}
|
||||
logpath = /var/log/novacpx/access.log
|
||||
maxretry = 10
|
||||
F2B
|
||||
systemctl enable fail2ban >> "$LOG" 2>&1
|
||||
systemctl restart fail2ban >> "$LOG" 2>&1
|
||||
@@ -515,6 +593,7 @@ cat <<DONE
|
||||
║ User Panel: https://${SERVER_IP}:${PORT_USER}
|
||||
║ Reseller Panel: https://${SERVER_IP}:${PORT_RESELLER}
|
||||
║ Admin Panel: https://${SERVER_IP}:${PORT_ADMIN}
|
||||
║ Webmail: https://${SERVER_IP}:${PORT_WEBMAIL}
|
||||
║ Username: admin
|
||||
║ Password: ${ADMIN_PASS}
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* AccountManager — creates/suspends/terminates Linux hosting accounts
|
||||
* Each account = system user + home dir + vhost + DNS zone + mail domain
|
||||
*/
|
||||
class AccountManager {
|
||||
|
||||
public static function create(array $data): array {
|
||||
$db = DB::getInstance();
|
||||
$username = strtolower(preg_replace('/[^a-z0-9_]/', '', $data['username']));
|
||||
$domain = strtolower(trim($data['domain']));
|
||||
$userId = (int)$data['user_id'];
|
||||
$pkgId = (int)($data['package_id'] ?? 0);
|
||||
$phpVer = $data['php_version'] ?? PHP_DEFAULT;
|
||||
$webSrv = WEB_SERVER;
|
||||
|
||||
if (strlen($username) < 2 || strlen($username) > 32) throw new RuntimeException("Username must be 2-32 chars");
|
||||
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) throw new RuntimeException("Invalid domain");
|
||||
|
||||
// Check uniqueness
|
||||
if ($db->fetchOne("SELECT id FROM accounts WHERE username = ?", [$username])) throw new RuntimeException("Username taken");
|
||||
if ($db->fetchOne("SELECT id FROM domains WHERE domain = ?", [$domain])) throw new RuntimeException("Domain already hosted");
|
||||
|
||||
$homeDir = "/home/{$username}";
|
||||
$docRoot = "{$homeDir}/public_html";
|
||||
$password = $data['password'] ?? bin2hex(random_bytes(8));
|
||||
|
||||
// Create Linux user
|
||||
self::shell("useradd -m -d {$homeDir} -s /sbin/nologin -G www-data " . escapeshellarg($username));
|
||||
self::shell("echo " . escapeshellarg("{$username}:{$password}") . " | chpasswd");
|
||||
self::shell("mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp");
|
||||
self::shell("chown -R {$username}:www-data {$homeDir}");
|
||||
self::shell("chmod 750 {$homeDir}; chmod 755 {$docRoot}");
|
||||
|
||||
// Default index page
|
||||
file_put_contents("{$docRoot}/index.html",
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>"
|
||||
);
|
||||
|
||||
// Save account to DB
|
||||
$acctId = (int)$db->insert(
|
||||
"INSERT INTO accounts (user_id, username, domain, home_dir, package_id, php_version, web_server) VALUES (?,?,?,?,?,?,?)",
|
||||
[$userId, $username, $domain, $homeDir, $pkgId ?: null, $phpVer, $webSrv]
|
||||
);
|
||||
|
||||
// Save domain
|
||||
$db->insert(
|
||||
"INSERT INTO domains (account_id, domain, type, document_root) VALUES (?,?,?,?)",
|
||||
[$acctId, $domain, 'main', $docRoot]
|
||||
);
|
||||
|
||||
// Create web vhost
|
||||
VhostManager::create($username, $domain, $docRoot, $phpVer);
|
||||
|
||||
// Create DNS zone
|
||||
DNSManager::createZone($acctId, $domain);
|
||||
|
||||
// Create PHP-FPM pool
|
||||
PHPManager::createPool($username, $phpVer);
|
||||
|
||||
novacpx_log('info', "Account created: $username ($domain)");
|
||||
return ['account_id' => $acctId, 'username' => $username, 'domain' => $domain, 'home_dir' => $homeDir];
|
||||
}
|
||||
|
||||
public static function suspend(int $acctId, string $reason = ''): void {
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
self::shell("usermod -L " . escapeshellarg($acct['username']));
|
||||
VhostManager::suspend($acct['username'], $acct['domain']);
|
||||
$db->execute("UPDATE accounts SET status = 'suspended', suspended_at = NOW() WHERE id = ?", [$acctId]);
|
||||
novacpx_log('info', "Account suspended: {$acct['username']} — $reason");
|
||||
}
|
||||
|
||||
public static function unsuspend(int $acctId): void {
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
self::shell("usermod -U " . escapeshellarg($acct['username']));
|
||||
VhostManager::unsuspend($acct['username'], $acct['domain']);
|
||||
$db->execute("UPDATE accounts SET status = 'active', suspended_at = NULL WHERE id = ?", [$acctId]);
|
||||
}
|
||||
|
||||
public static function terminate(int $acctId): void {
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$acctId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
// Remove vhost
|
||||
VhostManager::remove($acct['username'], $acct['domain']);
|
||||
|
||||
// Remove DNS zone
|
||||
DNSManager::removeZone($acct['domain']);
|
||||
|
||||
// Drop databases
|
||||
$dbs = $db->fetchAll("SELECT * FROM databases WHERE account_id = ?", [$acctId]);
|
||||
foreach ($dbs as $dbe) { DatabaseManager::drop($dbe['db_name'], $dbe['db_user'], $dbe['db_type']); }
|
||||
|
||||
// Remove PHP-FPM pool
|
||||
PHPManager::removePool($acct['username']);
|
||||
|
||||
// Remove Linux user and home dir
|
||||
self::shell("userdel -r " . escapeshellarg($acct['username']) . " 2>/dev/null || true");
|
||||
|
||||
// Remove from DB (cascade handles child tables)
|
||||
$db->execute("DELETE FROM users WHERE id = ?", [$acct['user_id']]);
|
||||
$db->execute("DELETE FROM accounts WHERE id = ?", [$acctId]);
|
||||
novacpx_log('info', "Account terminated: {$acct['username']}");
|
||||
}
|
||||
|
||||
public static function getDiskUsage(string $homeDir): int {
|
||||
$out = trim(shell_exec("du -sm " . escapeshellarg($homeDir) . " 2>/dev/null | awk '{print $1}'") ?: '0');
|
||||
return (int)$out;
|
||||
}
|
||||
|
||||
private static function shell(string $cmd): string {
|
||||
$out = shell_exec($cmd . ' 2>&1');
|
||||
novacpx_log('debug', "shell: $cmd");
|
||||
return $out ?: '';
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -22,6 +22,7 @@ define('PANEL_VER', $_cfg['panel']['version'] ?? NOVACPX_VERSION);
|
||||
define('PORT_USER', (int)($_cfg['panel']['port_user'] ?? 8880));
|
||||
define('PORT_RESELLER', (int)($_cfg['panel']['port_reseller'] ?? 8881));
|
||||
define('PORT_ADMIN', (int)($_cfg['panel']['port_admin'] ?? 8882));
|
||||
define('PORT_WEBMAIL', (int)($_cfg['panel']['port_webmail'] ?? 8883));
|
||||
define('WEB_SERVER', $_cfg['web']['server'] ?? 'apache');
|
||||
define('PHP_DEFAULT', $_cfg['web']['php_default'] ?? '8.3');
|
||||
|
||||
@@ -29,7 +30,8 @@ define('PHP_DEFAULT', $_cfg['web']['php_default'] ?? '8.3');
|
||||
$requestPort = (int)($_SERVER['SERVER_PORT'] ?? 0);
|
||||
define('CURRENT_PORTAL',
|
||||
$requestPort === PORT_ADMIN ? 'admin' :
|
||||
($requestPort === PORT_RESELLER ? 'reseller' : 'user')
|
||||
($requestPort === PORT_RESELLER ? 'reseller' :
|
||||
($requestPort === PORT_WEBMAIL ? 'webmail' : 'user'))
|
||||
);
|
||||
|
||||
function novacpx_log(string $level, string $msg, array $ctx = []): void {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* DNSManager — BIND9 zone file generation and management
|
||||
*/
|
||||
class DNSManager {
|
||||
|
||||
private static string $zonesDir = '/etc/bind/novacpx-zones';
|
||||
private static string $namedConf = '/etc/bind/named.conf.novacpx';
|
||||
|
||||
public static function createZone(int $accountId, string $domain): void {
|
||||
$db = DB::getInstance();
|
||||
$serial = (int)date('Ymd') * 100 + 1;
|
||||
$ns1 = self::getSetting('default_nameserver1', 'ns1.localhost');
|
||||
$ns2 = self::getSetting('default_nameserver2', 'ns2.localhost');
|
||||
$email = 'hostmaster.' . $domain;
|
||||
$ip = self::serverIp();
|
||||
|
||||
$zoneId = (int)$db->insert(
|
||||
"INSERT INTO dns_zones (account_id, domain, serial, primary_ns, secondary_ns, admin_email) VALUES (?,?,?,?,?,?)",
|
||||
[$accountId, $domain, $serial, $ns1, $ns2, $email]
|
||||
);
|
||||
|
||||
// Default records
|
||||
$defaults = [
|
||||
['@', 'A', $ip, 3600, null],
|
||||
['www', 'A', $ip, 3600, null],
|
||||
['mail', 'A', $ip, 3600, null],
|
||||
['@', 'MX', "mail.{$domain}.", 3600, 10],
|
||||
['@', 'TXT', "v=spf1 a mx ~all", 3600, null],
|
||||
];
|
||||
foreach ($defaults as [$name, $type, $content, $ttl, $prio]) {
|
||||
$db->execute(
|
||||
"INSERT INTO dns_records (zone_id, name, type, content, ttl, priority) VALUES (?,?,?,?,?,?)",
|
||||
[$zoneId, $name, $type, $content, $ttl, $prio]
|
||||
);
|
||||
}
|
||||
|
||||
self::writeZoneFile($zoneId);
|
||||
self::reloadBind();
|
||||
}
|
||||
|
||||
public static function removeZone(string $domain): void {
|
||||
$db = DB::getInstance();
|
||||
$zone = $db->fetchOne("SELECT id FROM dns_zones WHERE domain = ?", [$domain]);
|
||||
if ($zone) {
|
||||
$db->execute("DELETE FROM dns_zones WHERE id = ?", [$zone['id']]);
|
||||
}
|
||||
$file = self::$zonesDir . '/' . $domain . '.zone';
|
||||
@unlink($file);
|
||||
self::rebuildNamedConf();
|
||||
self::reloadBind();
|
||||
}
|
||||
|
||||
public static function addRecord(int $zoneId, string $name, string $type, string $content, int $ttl = 3600, ?int $priority = null): int {
|
||||
$db = DB::getInstance();
|
||||
$id = (int)$db->insert(
|
||||
"INSERT INTO dns_records (zone_id, name, type, content, ttl, priority) VALUES (?,?,?,?,?,?)",
|
||||
[$zoneId, $name, $type, $content, $ttl, $priority]
|
||||
);
|
||||
$db->execute("UPDATE dns_zones SET serial = serial + 1, updated_at = NOW() WHERE id = ?", [$zoneId]);
|
||||
self::writeZoneFile($zoneId);
|
||||
self::reloadBind();
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function updateRecord(int $recordId, array $data): void {
|
||||
$db = DB::getInstance();
|
||||
$rec = $db->fetchOne("SELECT zone_id FROM dns_records WHERE id = ?", [$recordId]);
|
||||
if (!$rec) throw new RuntimeException("Record not found");
|
||||
$db->execute(
|
||||
"UPDATE dns_records SET name=?, type=?, content=?, ttl=?, priority=? WHERE id=?",
|
||||
[$data['name'], $data['type'], $data['content'], $data['ttl'] ?? 3600, $data['priority'] ?? null, $recordId]
|
||||
);
|
||||
$db->execute("UPDATE dns_zones SET serial = serial + 1, updated_at = NOW() WHERE id = ?", [$rec['zone_id']]);
|
||||
self::writeZoneFile($rec['zone_id']);
|
||||
self::reloadBind();
|
||||
}
|
||||
|
||||
public static function deleteRecord(int $recordId): void {
|
||||
$db = DB::getInstance();
|
||||
$rec = $db->fetchOne("SELECT zone_id FROM dns_records WHERE id = ?", [$recordId]);
|
||||
if (!$rec) throw new RuntimeException("Record not found");
|
||||
$db->execute("DELETE FROM dns_records WHERE id = ?", [$recordId]);
|
||||
$db->execute("UPDATE dns_zones SET serial = serial + 1 WHERE id = ?", [$rec['zone_id']]);
|
||||
self::writeZoneFile($rec['zone_id']);
|
||||
self::reloadBind();
|
||||
}
|
||||
|
||||
public static function writeZoneFile(int $zoneId): void {
|
||||
$db = DB::getInstance();
|
||||
$zone = $db->fetchOne("SELECT * FROM dns_zones WHERE id = ?", [$zoneId]);
|
||||
if (!$zone) return;
|
||||
$records = $db->fetchAll("SELECT * FROM dns_records WHERE zone_id = ? ORDER BY type, name", [$zoneId]);
|
||||
|
||||
@mkdir(self::$zonesDir, 0755, true);
|
||||
$domain = $zone['domain'];
|
||||
$content = "\$ORIGIN {$domain}.\n\$TTL {$zone['ttl']}\n\n";
|
||||
$content .= "@ IN SOA {$zone['primary_ns']}. {$zone['admin_email']}. (\n";
|
||||
$content .= " {$zone['serial']} ; serial\n";
|
||||
$content .= " {$zone['refresh']} ; refresh\n";
|
||||
$content .= " {$zone['retry']} ; retry\n";
|
||||
$content .= " {$zone['expire']} ; expire\n";
|
||||
$content .= " {$zone['minimum']} ; minimum\n)\n\n";
|
||||
$content .= "@ IN NS {$zone['primary_ns']}.\n";
|
||||
$content .= "@ IN NS {$zone['secondary_ns']}.\n\n";
|
||||
|
||||
foreach ($records as $r) {
|
||||
$name = $r['name'] === '@' ? '@' : $r['name'];
|
||||
$prio = $r['priority'] !== null ? "{$r['priority']} " : '';
|
||||
$val = in_array($r['type'], ['TXT','SPF','DMARC','DKIM']) ? "\"{$r['content']}\"" : $r['content'];
|
||||
$content .= "{$name} {$r['ttl']} IN {$r['type']} {$prio}{$val}\n";
|
||||
}
|
||||
|
||||
file_put_contents(self::$zonesDir . '/' . $domain . '.zone', $content);
|
||||
self::rebuildNamedConf();
|
||||
}
|
||||
|
||||
private static function rebuildNamedConf(): void {
|
||||
@mkdir(self::$zonesDir, 0755, true);
|
||||
$zones = glob(self::$zonesDir . '/*.zone') ?: [];
|
||||
$conf = "// NovaCPX auto-generated zone list\n";
|
||||
foreach ($zones as $zf) {
|
||||
$domain = basename($zf, '.zone');
|
||||
$conf .= "zone \"{$domain}\" { type master; file \"" . self::$zonesDir . "/{$domain}.zone\"; };\n";
|
||||
}
|
||||
file_put_contents(self::$namedConf, $conf);
|
||||
|
||||
// Include in main named.conf if not already there
|
||||
$mainConf = '/etc/bind/named.conf';
|
||||
if (file_exists($mainConf) && !str_contains(file_get_contents($mainConf), 'named.conf.novacpx')) {
|
||||
file_put_contents($mainConf, "\ninclude \"" . self::$namedConf . "\";\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
private static function reloadBind(): void {
|
||||
shell_exec("rndc reload 2>/dev/null || systemctl reload named 2>/dev/null || true");
|
||||
}
|
||||
|
||||
private static function serverIp(): string {
|
||||
return trim(shell_exec("hostname -I | awk '{print $1}'") ?: '127.0.0.1');
|
||||
}
|
||||
|
||||
private static function getSetting(string $key, string $default): string {
|
||||
$row = DB::getInstance()->fetchOne("SELECT value FROM settings WHERE `key` = ?", [$key]);
|
||||
return $row['value'] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/**
|
||||
* DatabaseManager — MySQL 8 + PostgreSQL database/user provisioning
|
||||
*/
|
||||
class DatabaseManager {
|
||||
|
||||
public static function createMySQL(int $accountId, string $dbName, string $dbUser, string $dbPass): int {
|
||||
self::validateName($dbName); self::validateName($dbUser);
|
||||
$db = DB::getInstance();
|
||||
$pdo = $db->pdo();
|
||||
|
||||
$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
$pdo->exec("CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY " . $pdo->quote($dbPass));
|
||||
$pdo->exec("GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'localhost'");
|
||||
$pdo->exec("FLUSH PRIVILEGES");
|
||||
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'mysql']
|
||||
);
|
||||
}
|
||||
|
||||
public static function createPostgres(int $accountId, string $dbName, string $dbUser, string $dbPass): int {
|
||||
self::validateName($dbName); self::validateName($dbUser);
|
||||
$db = DB::getInstance();
|
||||
$safe = escapeshellarg($dbPass);
|
||||
shell_exec("sudo -u postgres psql -c \"CREATE USER {$dbUser} WITH PASSWORD {$safe}\" 2>/dev/null");
|
||||
shell_exec("sudo -u postgres createdb -O {$dbUser} {$dbName} 2>/dev/null");
|
||||
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO databases (account_id, db_name, db_user, db_pass, db_type) VALUES (?,?,?,?,?)",
|
||||
[$accountId, $dbName, $dbUser, encrypt($dbPass), 'postgresql']
|
||||
);
|
||||
}
|
||||
|
||||
public static function drop(string $dbName, string $dbUser, string $type = 'mysql'): void {
|
||||
if ($type === 'mysql') {
|
||||
$pdo = DB::getInstance()->pdo();
|
||||
$pdo->exec("DROP DATABASE IF EXISTS `{$dbName}`");
|
||||
$pdo->exec("DROP USER IF EXISTS '{$dbUser}'@'localhost'");
|
||||
$pdo->exec("FLUSH PRIVILEGES");
|
||||
} else {
|
||||
shell_exec("sudo -u postgres dropdb --if-exists " . escapeshellarg($dbName) . " 2>/dev/null");
|
||||
shell_exec("sudo -u postgres dropuser --if-exists " . escapeshellarg($dbUser) . " 2>/dev/null");
|
||||
}
|
||||
DB::getInstance()->execute("DELETE FROM databases WHERE db_name = ? AND db_type = ?", [$dbName, $type]);
|
||||
}
|
||||
|
||||
public static function changePassword(int $id, string $newPass): void {
|
||||
$db = DB::getInstance();
|
||||
$dbe = $db->fetchOne("SELECT * FROM databases WHERE id = ?", [$id]);
|
||||
if (!$dbe) throw new RuntimeException("Database not found");
|
||||
if ($dbe['db_type'] === 'mysql') {
|
||||
$pdo = $db->pdo();
|
||||
$pdo->exec("ALTER USER '{$dbe['db_user']}'@'localhost' IDENTIFIED BY " . $pdo->quote($newPass));
|
||||
} else {
|
||||
shell_exec("sudo -u postgres psql -c \"ALTER USER {$dbe['db_user']} WITH PASSWORD " . escapeshellarg($newPass) . "\" 2>/dev/null");
|
||||
}
|
||||
$db->execute("UPDATE databases SET db_pass = ? WHERE id = ?", [encrypt($newPass), $id]);
|
||||
}
|
||||
|
||||
public static function getSize(string $dbName, string $type = 'mysql'): float {
|
||||
if ($type === 'mysql') {
|
||||
$row = DB::getInstance()->fetchOne(
|
||||
"SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size
|
||||
FROM information_schema.tables WHERE table_schema = ?",
|
||||
[$dbName]
|
||||
);
|
||||
return (float)($row['size'] ?? 0);
|
||||
}
|
||||
$out = shell_exec("sudo -u postgres psql -t -c \"SELECT pg_size_pretty(pg_database_size('{$dbName}'))\" 2>/dev/null");
|
||||
return (float)$out;
|
||||
}
|
||||
|
||||
private static function validateName(string $name): void {
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{1,64}$/', $name)) {
|
||||
throw new RuntimeException("Invalid database/user name: must be alphanumeric+underscore, max 64 chars");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function encrypt(string $val): string {
|
||||
$key = SECRET_KEY;
|
||||
$iv = random_bytes(16);
|
||||
$enc = openssl_encrypt($val, 'aes-256-cbc', substr(hash('sha256', $key, true), 0, 32), OPENSSL_RAW_DATA, $iv);
|
||||
return base64_encode($iv . $enc);
|
||||
}
|
||||
|
||||
function decrypt(string $val): string {
|
||||
$key = SECRET_KEY;
|
||||
$data = base64_decode($val);
|
||||
$iv = substr($data, 0, 16);
|
||||
$enc = substr($data, 16);
|
||||
return openssl_decrypt($enc, 'aes-256-cbc', substr(hash('sha256', $key, true), 0, 32), OPENSSL_RAW_DATA, $iv) ?: '';
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* EmailManager — Postfix virtual mailbox + Dovecot user management
|
||||
* Uses MySQL backend for both Postfix and Dovecot
|
||||
*/
|
||||
class EmailManager {
|
||||
|
||||
public static function createAccount(int $accountId, string $email, string $password, int $quotaMb = 500): int {
|
||||
$db = DB::getInstance();
|
||||
$hashed = self::hashPassword($password);
|
||||
$id = (int)$db->insert(
|
||||
"INSERT INTO email_accounts (account_id, email, password, quota_mb) VALUES (?,?,?,?)",
|
||||
[$accountId, $email, $hashed, $quotaMb]
|
||||
);
|
||||
self::syncPostfix();
|
||||
novacpx_log('info', "Email account created: $email");
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function deleteAccount(int $id): void {
|
||||
$db = DB::getInstance();
|
||||
$acc = $db->fetchOne("SELECT email FROM email_accounts WHERE id = ?", [$id]);
|
||||
if (!$acc) throw new RuntimeException("Email account not found");
|
||||
$db->execute("DELETE FROM email_accounts WHERE id = ?", [$id]);
|
||||
self::syncPostfix();
|
||||
}
|
||||
|
||||
public static function changePassword(int $id, string $newPassword): void {
|
||||
$db = DB::getInstance();
|
||||
$db->execute("UPDATE email_accounts SET password = ? WHERE id = ?", [self::hashPassword($newPassword), $id]);
|
||||
}
|
||||
|
||||
public static function suspend(int $id): void {
|
||||
DB::getInstance()->execute("UPDATE email_accounts SET status = 'suspended' WHERE id = ?", [$id]);
|
||||
self::syncPostfix();
|
||||
}
|
||||
|
||||
public static function addForwarder(int $accountId, string $source, string $destination): int {
|
||||
$db = DB::getInstance();
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO email_forwarders (account_id, source, destination) VALUES (?,?,?)",
|
||||
[$accountId, $source, $destination]
|
||||
);
|
||||
}
|
||||
|
||||
public static function removeForwarder(int $id): void {
|
||||
DB::getInstance()->execute("DELETE FROM email_forwarders WHERE id = ?", [$id]);
|
||||
self::syncPostfix();
|
||||
}
|
||||
|
||||
public static function addAutoresponder(int $accountId, string $email, string $subject, string $body): int {
|
||||
$db = DB::getInstance();
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO email_autoresponders (account_id, email, subject, body, is_active) VALUES (?,?,?,?,1)",
|
||||
[$accountId, $email, $subject, $body]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Postfix virtual_mailbox_maps + virtual_alias_maps files from DB
|
||||
* Postfix reads these files (postmap creates .db hash)
|
||||
*/
|
||||
private static function syncPostfix(): void {
|
||||
$db = DB::getInstance();
|
||||
|
||||
// Virtual mailbox map
|
||||
$accounts = $db->fetchAll("SELECT ea.email, a.username FROM email_accounts ea JOIN accounts a ON a.id = ea.account_id WHERE ea.status = 'active'");
|
||||
$mailboxes = '';
|
||||
foreach ($accounts as $a) {
|
||||
$domain = substr(strrchr($a['email'], '@'), 1);
|
||||
$user = strstr($a['email'], '@', true);
|
||||
$mailboxes .= "{$a['email']} {$a['username']}/{$domain}/{$user}/\n";
|
||||
}
|
||||
file_put_contents('/etc/postfix/novacpx_mailboxes', $mailboxes);
|
||||
shell_exec('postmap /etc/postfix/novacpx_mailboxes 2>/dev/null');
|
||||
|
||||
// Virtual alias map (forwarders)
|
||||
$forwarders = $db->fetchAll("SELECT source, destination FROM email_forwarders");
|
||||
$aliases = '';
|
||||
foreach ($forwarders as $f) {
|
||||
$aliases .= "{$f['source']} {$f['destination']}\n";
|
||||
}
|
||||
file_put_contents('/etc/postfix/novacpx_aliases', $aliases);
|
||||
shell_exec('postmap /etc/postfix/novacpx_aliases 2>/dev/null');
|
||||
|
||||
// Virtual domains map
|
||||
$domains = $db->fetchAll("SELECT DISTINCT SUBSTRING_INDEX(email,'@',-1) as domain FROM email_accounts WHERE status='active'");
|
||||
$vdomains = '';
|
||||
foreach ($domains as $d) { $vdomains .= "{$d['domain']} novacpx\n"; }
|
||||
file_put_contents('/etc/postfix/novacpx_domains', $vdomains);
|
||||
shell_exec('postmap /etc/postfix/novacpx_domains 2>/dev/null');
|
||||
shell_exec('systemctl reload postfix 2>/dev/null || true');
|
||||
}
|
||||
|
||||
private static function hashPassword(string $password): string {
|
||||
// Dovecot SHA512-CRYPT compatible
|
||||
return '{SHA512-CRYPT}' . crypt($password, '$6$' . bin2hex(random_bytes(8)) . '$');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* FTPManager — ProFTPD virtual user management via MySQL
|
||||
*/
|
||||
class FTPManager {
|
||||
|
||||
public static function createAccount(int $accountId, string $username, string $password, string $homeDir, int $quotaMb = 0): int {
|
||||
$db = DB::getInstance();
|
||||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
// Create system user for FTP (no shell, no home dir creation)
|
||||
$acct = $db->fetchOne("SELECT username as owner FROM accounts WHERE id = ?", [$accountId]);
|
||||
$ftpUser = strtolower(preg_replace('/[^a-z0-9_]/', '', $username));
|
||||
|
||||
$id = (int)$db->insert(
|
||||
"INSERT INTO ftp_accounts (account_id, username, password, home_dir, quota_mb) VALUES (?,?,?,?,?)",
|
||||
[$accountId, $ftpUser, $hashed, $homeDir, $quotaMb]
|
||||
);
|
||||
|
||||
self::syncProftpd();
|
||||
novacpx_log('info', "FTP account created: $ftpUser");
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function deleteAccount(int $id): void {
|
||||
DB::getInstance()->execute("DELETE FROM ftp_accounts WHERE id = ?", [$id]);
|
||||
self::syncProftpd();
|
||||
}
|
||||
|
||||
public static function changePassword(int $id, string $newPassword): void {
|
||||
DB::getInstance()->execute(
|
||||
"UPDATE ftp_accounts SET password = ? WHERE id = ?",
|
||||
[password_hash($newPassword, PASSWORD_BCRYPT), $id]
|
||||
);
|
||||
self::syncProftpd();
|
||||
}
|
||||
|
||||
public static function suspend(int $id): void {
|
||||
DB::getInstance()->execute("UPDATE ftp_accounts SET status = 'suspended' WHERE id = ?", [$id]);
|
||||
self::syncProftpd();
|
||||
}
|
||||
|
||||
private static function syncProftpd(): void {
|
||||
// Write ProFTPD virtual users file (passwd format)
|
||||
$db = DB::getInstance();
|
||||
$accounts = $db->fetchAll("SELECT f.*, a.username as owner FROM ftp_accounts f JOIN accounts a ON a.id = f.account_id WHERE f.status = 'active'");
|
||||
$passwd = '';
|
||||
foreach ($accounts as $a) {
|
||||
$uid = self::getUid($a['owner']);
|
||||
$gid = self::getGid('www-data');
|
||||
$passwd .= "{$a['username']}:{$a['password']}:{$uid}:{$gid}:NovaCPX FTP:{$a['home_dir']}:/sbin/nologin\n";
|
||||
}
|
||||
file_put_contents('/etc/proftpd/novacpx-users.passwd', $passwd);
|
||||
shell_exec('systemctl reload proftpd 2>/dev/null || true');
|
||||
}
|
||||
|
||||
private static function getUid(string $username): int {
|
||||
$out = trim(shell_exec("id -u " . escapeshellarg($username) . " 2>/dev/null") ?: '33');
|
||||
return (int)$out;
|
||||
}
|
||||
|
||||
private static function getGid(string $group): int {
|
||||
$out = trim(shell_exec("getent group " . escapeshellarg($group) . " | cut -d: -f3 2>/dev/null") ?: '33');
|
||||
return (int)$out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* PHPManager — per-account PHP-FPM pools + version switching
|
||||
*/
|
||||
class PHPManager {
|
||||
|
||||
private static string $poolDir = '/etc/php/{ver}/fpm/pool.d';
|
||||
|
||||
public static function createPool(string $username, string $phpVer): void {
|
||||
$poolFile = str_replace('{ver}', $phpVer, self::$poolDir) . "/{$username}.conf";
|
||||
$homeDir = "/home/{$username}";
|
||||
$sock = "/run/php/php{$phpVer}-fpm-{$username}.sock";
|
||||
|
||||
file_put_contents($poolFile, "[{$username}]
|
||||
user = {$username}
|
||||
group = www-data
|
||||
listen = {$sock}
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
listen.mode = 0660
|
||||
|
||||
pm = ondemand
|
||||
pm.max_children = 5
|
||||
pm.process_idle_timeout = 10s
|
||||
pm.max_requests = 500
|
||||
|
||||
php_admin_value[error_log] = {$homeDir}/logs/php.log
|
||||
php_admin_value[open_basedir] = {$homeDir}/:/tmp/
|
||||
php_admin_flag[log_errors] = on
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
|
||||
php_value[upload_max_filesize] = 64M
|
||||
php_value[post_max_size] = 64M
|
||||
php_value[memory_limit] = 256M
|
||||
php_value[max_execution_time] = 30
|
||||
");
|
||||
self::reloadFPM($phpVer);
|
||||
}
|
||||
|
||||
public static function removePool(string $username): void {
|
||||
foreach (['7.4','8.1','8.2','8.3'] as $ver) {
|
||||
$file = str_replace('{ver}', $ver, self::$poolDir) . "/{$username}.conf";
|
||||
if (file_exists($file)) { unlink($file); self::reloadFPM($ver); }
|
||||
}
|
||||
}
|
||||
|
||||
public static function switchVersion(int $accountId, string $newVer): void {
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE id = ?", [$accountId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
$oldVer = $acct['php_version'];
|
||||
if ($oldVer === $newVer) return;
|
||||
|
||||
// Remove old pool, create new one
|
||||
$oldPool = str_replace('{ver}', $oldVer, self::$poolDir) . "/{$acct['username']}.conf";
|
||||
if (file_exists($oldPool)) { unlink($oldPool); self::reloadFPM($oldVer); }
|
||||
|
||||
self::createPool($acct['username'], $newVer);
|
||||
|
||||
// Update vhost to use new socket
|
||||
VhostManager::create($acct['username'], $acct['domain'], $acct['home_dir'] . '/public_html', $newVer);
|
||||
|
||||
$db->execute("UPDATE accounts SET php_version = ? WHERE id = ?", [$newVer, $accountId]);
|
||||
$db->execute("UPDATE php_configs SET php_version = ?, updated_at = NOW() WHERE account_id = ?", [$newVer, $accountId]);
|
||||
}
|
||||
|
||||
public static function updateConfig(int $accountId, array $cfg): void {
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT username, php_version FROM accounts WHERE id = ?", [$accountId]);
|
||||
if (!$acct) throw new RuntimeException("Account not found");
|
||||
|
||||
$poolFile = str_replace('{ver}', $acct['php_version'], self::$poolDir) . "/{$acct['username']}.conf";
|
||||
if (!file_exists($poolFile)) throw new RuntimeException("PHP-FPM pool not found");
|
||||
|
||||
$content = file_get_contents($poolFile);
|
||||
$map = [
|
||||
'memory_limit' => 'php_value[memory_limit]',
|
||||
'max_execution_time' => 'php_value[max_execution_time]',
|
||||
'upload_max_filesize' => 'php_value[upload_max_filesize]',
|
||||
'post_max_size' => 'php_value[post_max_size]',
|
||||
];
|
||||
foreach ($map as $key => $iniKey) {
|
||||
if (isset($cfg[$key])) {
|
||||
$content = preg_replace("/{$iniKey}\s*=.*/", "{$iniKey} = {$cfg[$key]}", $content);
|
||||
}
|
||||
}
|
||||
file_put_contents($poolFile, $content);
|
||||
self::reloadFPM($acct['php_version']);
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO php_configs (account_id, php_version, memory_limit, max_execution_time, upload_max_filesize, post_max_size)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE memory_limit=VALUES(memory_limit), max_execution_time=VALUES(max_execution_time),
|
||||
upload_max_filesize=VALUES(upload_max_filesize), post_max_size=VALUES(post_max_size), updated_at=NOW()",
|
||||
[$accountId, $acct['php_version'], $cfg['memory_limit'] ?? '256M', $cfg['max_execution_time'] ?? 30,
|
||||
$cfg['upload_max_filesize'] ?? '64M', $cfg['post_max_size'] ?? '64M']
|
||||
);
|
||||
}
|
||||
|
||||
public static function listExtensions(string $phpVer): array {
|
||||
$out = shell_exec("php{$phpVer} -m 2>/dev/null") ?: '';
|
||||
return array_values(array_filter(explode("\n", $out), fn($l) => $l && !str_starts_with($l, '[')));
|
||||
}
|
||||
|
||||
private static function reloadFPM(string $ver): void {
|
||||
shell_exec("systemctl reload php{$ver}-fpm 2>/dev/null || true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* SSLManager — Let's Encrypt (Certbot) + custom certificate management
|
||||
*/
|
||||
class SSLManager {
|
||||
|
||||
public static function issueLetsEncrypt(int $accountId, string $domain, string $email = ''): array {
|
||||
$db = DB::getInstance();
|
||||
$webRoot = $db->fetchOne("SELECT d.document_root FROM domains d WHERE d.domain = ? AND d.account_id = ?", [$domain, $accountId]);
|
||||
if (!$webRoot) throw new RuntimeException("Domain not found for this account");
|
||||
|
||||
$docRoot = $webRoot['document_root'];
|
||||
$email = $email ?: "ssl@{$domain}";
|
||||
$cmd = "certbot certonly --webroot -w {$docRoot} -d {$domain} -d www.{$domain}"
|
||||
. " --email " . escapeshellarg($email)
|
||||
. " --agree-tos --non-interactive 2>&1";
|
||||
$out = shell_exec($cmd);
|
||||
|
||||
$certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
|
||||
$keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem";
|
||||
$chainPath = "/etc/letsencrypt/live/{$domain}/chain.pem";
|
||||
|
||||
if (!file_exists($certPath)) {
|
||||
novacpx_log('error', "Certbot failed for $domain: $out");
|
||||
throw new RuntimeException("Certbot failed. Check DNS propagation and try again.");
|
||||
}
|
||||
|
||||
$cert = file_get_contents($certPath);
|
||||
$key = file_get_contents($keyPath);
|
||||
$chain = file_get_contents($chainPath);
|
||||
|
||||
// Parse expiry
|
||||
$expiryRaw = shell_exec("openssl x509 -enddate -noout -in " . escapeshellarg($certPath));
|
||||
preg_match('/notAfter=(.+)/', $expiryRaw ?: '', $m);
|
||||
$expires = isset($m[1]) ? date('Y-m-d', strtotime($m[1])) : null;
|
||||
|
||||
// Store in DB
|
||||
$certId = self::storeCert($accountId, $domain, 'lets_encrypt', $cert, $key, $chain, $expires);
|
||||
|
||||
// Install on vhost
|
||||
$acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]);
|
||||
VhostManager::enableSSL($acct['username'], $domain, $cert, $key, $chain);
|
||||
|
||||
return ['cert_id' => $certId, 'expires' => $expires, 'output' => $out];
|
||||
}
|
||||
|
||||
public static function installCustom(int $accountId, string $domain, string $cert, string $key, string $chain = ''): int {
|
||||
$db = DB::getInstance();
|
||||
$expiryRaw = shell_exec("echo " . escapeshellarg($cert) . " | openssl x509 -enddate -noout 2>/dev/null");
|
||||
preg_match('/notAfter=(.+)/', $expiryRaw ?: '', $m);
|
||||
$expires = isset($m[1]) ? date('Y-m-d', strtotime($m[1])) : null;
|
||||
|
||||
$certId = self::storeCert($accountId, $domain, 'custom', $cert, $key, $chain, $expires);
|
||||
|
||||
$acct = $db->fetchOne("SELECT username FROM accounts WHERE id = ?", [$accountId]);
|
||||
VhostManager::enableSSL($acct['username'], $domain, $cert, $key, $chain);
|
||||
return $certId;
|
||||
}
|
||||
|
||||
public static function renewAll(): void {
|
||||
$db = DB::getInstance();
|
||||
$soon = $db->fetchAll(
|
||||
"SELECT * FROM ssl_certs WHERE auto_renew = 1 AND type = 'lets_encrypt'
|
||||
AND expires_at <= DATE_ADD(NOW(), INTERVAL 30 DAY) AND status = 'active'"
|
||||
);
|
||||
foreach ($soon as $cert) {
|
||||
try {
|
||||
self::issueLetsEncrypt($cert['account_id'], $cert['domain']);
|
||||
novacpx_log('info', "SSL auto-renewed: {$cert['domain']}");
|
||||
} catch (Throwable $e) {
|
||||
novacpx_log('error', "SSL renewal failed for {$cert['domain']}: " . $e->getMessage());
|
||||
$db->execute("UPDATE ssl_certs SET status = 'failed' WHERE id = ?", [$cert['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function storeCert(int $accountId, string $domain, string $type, string $cert, string $key, string $chain, ?string $expires): int {
|
||||
$db = DB::getInstance();
|
||||
$db->execute("DELETE FROM ssl_certs WHERE account_id = ? AND domain = ?", [$accountId, $domain]);
|
||||
return (int)$db->insert(
|
||||
"INSERT INTO ssl_certs (account_id, domain, type, cert, private_key, chain, issued_at, expires_at, status)
|
||||
VALUES (?,?,?,?,?,?,NOW(),?,?)",
|
||||
[$accountId, $domain, $type, $cert, $key, $chain, $expires, 'active']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
/**
|
||||
* VhostManager — creates/removes Apache2 and nginx virtual host configs
|
||||
*/
|
||||
class VhostManager {
|
||||
|
||||
public static function create(string $username, string $domain, string $docRoot, string $phpVer): void {
|
||||
$logDir = "/home/{$username}/logs";
|
||||
if (WEB_SERVER === 'nginx') {
|
||||
self::writeNginx($username, $domain, $docRoot, $phpVer, $logDir);
|
||||
} else {
|
||||
self::writeApache($username, $domain, $docRoot, $phpVer, $logDir);
|
||||
}
|
||||
self::reload();
|
||||
}
|
||||
|
||||
public static function createSubdomain(string $username, string $subdomain, string $docRoot, string $phpVer): void {
|
||||
self::create($username, $subdomain, $docRoot, $phpVer);
|
||||
}
|
||||
|
||||
public static function suspend(string $username, string $domain): void {
|
||||
$suspendedRoot = "/var/novacpx/suspended";
|
||||
@mkdir($suspendedRoot, 0755, true);
|
||||
$suspendPage = "{$suspendedRoot}/{$domain}.html";
|
||||
file_put_contents($suspendPage,
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:4rem;background:#0d0f17;color:#e2e4f0'>"
|
||||
. "<h1 style='color:#ef4444'>Account Suspended</h1>"
|
||||
. "<p>This account has been suspended. Please contact support.</p></body></html>"
|
||||
);
|
||||
// Rewrite vhost to serve suspension page
|
||||
if (WEB_SERVER === 'nginx') {
|
||||
$conf = "/etc/nginx/sites-available/novacpx-{$username}.conf";
|
||||
if (file_exists($conf)) {
|
||||
$content = file_get_contents($conf);
|
||||
$content = preg_replace('/root\s+[^;]+;/', "root {$suspendedRoot};", $content);
|
||||
file_put_contents($conf, $content);
|
||||
}
|
||||
} else {
|
||||
$conf = "/etc/apache2/sites-available/novacpx-{$username}.conf";
|
||||
if (file_exists($conf)) {
|
||||
$content = file_get_contents($conf);
|
||||
$content = preg_replace('/DocumentRoot\s+\S+/', "DocumentRoot {$suspendedRoot}", $content);
|
||||
file_put_contents($conf, $content);
|
||||
}
|
||||
}
|
||||
self::reload();
|
||||
}
|
||||
|
||||
public static function unsuspend(string $username, string $domain): void {
|
||||
// Re-create from DB
|
||||
$db = DB::getInstance();
|
||||
$acct = $db->fetchOne("SELECT * FROM accounts WHERE username = ?", [$username]);
|
||||
if ($acct) {
|
||||
self::create($username, $domain, $acct['home_dir'] . '/public_html', $acct['php_version']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function remove(string $username, string $domain): void {
|
||||
if (WEB_SERVER === 'nginx') {
|
||||
$conf = "/etc/nginx/sites-available/novacpx-{$username}.conf";
|
||||
$link = "/etc/nginx/sites-enabled/novacpx-{$username}.conf";
|
||||
@unlink($conf); @unlink($link);
|
||||
} else {
|
||||
$conf = "/etc/apache2/sites-available/novacpx-{$username}.conf";
|
||||
shell_exec("a2dissite novacpx-{$username} 2>/dev/null");
|
||||
@unlink($conf);
|
||||
}
|
||||
self::reload();
|
||||
}
|
||||
|
||||
public static function enableSSL(string $username, string $domain, string $cert, string $key, string $chain = ''): void {
|
||||
$certDir = "/etc/novacpx/ssl/accounts/{$username}";
|
||||
@mkdir($certDir, 0700, true);
|
||||
file_put_contents("{$certDir}/cert.pem", $cert);
|
||||
file_put_contents("{$certDir}/key.pem", $key);
|
||||
if ($chain) file_put_contents("{$certDir}/chain.pem", $chain);
|
||||
|
||||
if (WEB_SERVER === 'nginx') {
|
||||
$conf = file_get_contents("/etc/nginx/sites-available/novacpx-{$username}.conf") ?: '';
|
||||
if (!str_contains($conf, 'ssl_certificate')) {
|
||||
$conf = str_replace('listen 80;', "listen 443 ssl http2;\n listen 80;\n ssl_certificate {$certDir}/cert.pem;\n ssl_certificate_key {$certDir}/key.pem;", $conf);
|
||||
file_put_contents("/etc/nginx/sites-available/novacpx-{$username}.conf", $conf);
|
||||
}
|
||||
} else {
|
||||
$conf = file_get_contents("/etc/apache2/sites-available/novacpx-{$username}.conf") ?: '';
|
||||
if (!str_contains($conf, 'SSLEngine')) {
|
||||
$conf = str_replace('<VirtualHost *:80>', "<VirtualHost *:443>\n SSLEngine on\n SSLCertificateFile {$certDir}/cert.pem\n SSLCertificateKeyFile {$certDir}/key.pem", $conf);
|
||||
$conf .= "\n<VirtualHost *:80>\n ServerName {$domain}\n Redirect permanent / https://{$domain}/\n</VirtualHost>";
|
||||
file_put_contents("/etc/apache2/sites-available/novacpx-{$username}.conf", $conf);
|
||||
}
|
||||
}
|
||||
self::reload();
|
||||
}
|
||||
|
||||
private static function writeNginx(string $username, string $domain, string $docRoot, string $phpVer, string $logDir): void {
|
||||
$sock = "/run/php/php{$phpVer}-fpm-{$username}.sock";
|
||||
$conf = "/etc/nginx/sites-available/novacpx-{$username}.conf";
|
||||
file_put_contents($conf, "server {
|
||||
listen 80;
|
||||
server_name {$domain} www.{$domain};
|
||||
root {$docRoot};
|
||||
index index.php index.html index.htm;
|
||||
access_log {$logDir}/access.log;
|
||||
error_log {$logDir}/error.log;
|
||||
|
||||
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:{$sock};
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
|
||||
fastcgi_param PHP_VALUE \"error_log={$logDir}/php.log\";
|
||||
}
|
||||
location ~ /\\.ht { deny all; }
|
||||
location ~* \\.(jpg|jpeg|png|gif|ico|css|js|svg|woff2)$ { expires 30d; add_header Cache-Control public; }
|
||||
}
|
||||
");
|
||||
@symlink($conf, "/etc/nginx/sites-enabled/novacpx-{$username}.conf");
|
||||
}
|
||||
|
||||
private static function writeApache(string $username, string $domain, string $docRoot, string $phpVer, string $logDir): void {
|
||||
$sock = "/run/php/php{$phpVer}-fpm-{$username}.sock";
|
||||
$conf = "/etc/apache2/sites-available/novacpx-{$username}.conf";
|
||||
file_put_contents($conf, "<VirtualHost *:80>
|
||||
ServerName {$domain}
|
||||
ServerAlias www.{$domain}
|
||||
DocumentRoot {$docRoot}
|
||||
ErrorLog {$logDir}/error.log
|
||||
CustomLog {$logDir}/access.log combined
|
||||
|
||||
<Directory {$docRoot}>
|
||||
Options -Indexes +FollowSymLinks +MultiViews
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler \"proxy:unix:{$sock}|fcgi://localhost/\"
|
||||
</FilesMatch>
|
||||
</VirtualHost>
|
||||
");
|
||||
shell_exec("a2ensite novacpx-{$username} 2>/dev/null");
|
||||
}
|
||||
|
||||
private static function reload(): void {
|
||||
if (WEB_SERVER === 'nginx') {
|
||||
shell_exec("nginx -t 2>/dev/null && systemctl reload nginx 2>/dev/null");
|
||||
} else {
|
||||
shell_exec("apache2ctl configtest 2>/dev/null && systemctl reload apache2 2>/dev/null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0d0f17"/>
|
||||
<polygon points="16,3 27,9 27,21 16,27 5,21 5,9" fill="none" stroke="#6366f1" stroke-width="2"/>
|
||||
<circle cx="16" cy="15" r="5" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-dasharray="2 1.5"/>
|
||||
<circle cx="16" cy="15" r="2.5" fill="#6366f1"/>
|
||||
<circle cx="16" cy="9.5" r="1.2" fill="#6366f1"/>
|
||||
<circle cx="21" cy="18" r="1.2" fill="#0ea5e9"/>
|
||||
<circle cx="11" cy="18" r="1.2" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
@@ -0,0 +1,329 @@
|
||||
<!-- NovaCPX Custom Icon Sprite — inline <use href="#icon-name"/> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
|
||||
<!-- Dashboard / home -->
|
||||
<symbol id="ni-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="9" height="9" rx="1.5"/>
|
||||
<rect x="13" y="3" width="9" height="5" rx="1.5"/>
|
||||
<rect x="13" y="11" width="9" height="9" rx="1.5"/>
|
||||
<rect x="2" y="15" width="9" height="5" rx="1.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Accounts / users -->
|
||||
<symbol id="ni-accounts" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M2 21c0-4 3-7 7-7h4c4 0 7 3 7 7"/>
|
||||
<circle cx="19" cy="9" r="2.5"/>
|
||||
<path d="M22 19c0-2.5-1.5-4.5-3.5-5.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Resellers -->
|
||||
<symbol id="ni-resellers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 15,8.5 22,9.5 17,14 18.5,21 12,17.5 5.5,21 7,14 2,9.5 9,8.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Packages -->
|
||||
<symbol id="ni-packages" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- DNS -->
|
||||
<symbol id="ni-dns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Email -->
|
||||
<symbol id="ni-email" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
<line x1="12" y1="13" x2="12" y2="20"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Databases -->
|
||||
<symbol id="ni-databases" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- FTP -->
|
||||
<symbol id="ni-ftp" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="13" y="3" width="8" height="6" rx="1"/>
|
||||
<rect x="3" y="13" width="8" height="6" rx="1"/>
|
||||
<path d="M17 13v4m-2-2h4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- SSL / Lock -->
|
||||
<symbol id="ni-ssl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="11" width="14" height="11" rx="2"/>
|
||||
<path d="M8 11V7a4 4 0 1 1 8 0v4"/>
|
||||
<circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<line x1="12" y1="17.5" x2="12" y2="19.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- PHP -->
|
||||
<symbol id="ni-php" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="12" rx="10" ry="6"/>
|
||||
<path d="M9 10v4m0-4h2a1.5 1.5 0 0 1 0 3H9"/>
|
||||
<path d="M14 10v4m0-4h2a1.5 1.5 0 0 1 0 3H14"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cron / clock -->
|
||||
<symbol id="ni-cron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
<path d="M16 2l2 3M8 2L6 5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- File Manager -->
|
||||
<symbol id="ni-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Domains / Web -->
|
||||
<symbol id="ni-domains" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="13" rx="2"/>
|
||||
<line x1="3" y1="7" x2="21" y2="7"/>
|
||||
<line x1="7" y1="21" x2="17" y2="21"/>
|
||||
<line x1="12" y1="16" x2="12" y2="21"/>
|
||||
<circle cx="6" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
<circle cx="9" cy="5" r="0.8" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Server / System -->
|
||||
<symbol id="ni-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="7" rx="1.5"/>
|
||||
<rect x="2" y="13" width="20" height="7" rx="1.5"/>
|
||||
<circle cx="6" cy="6.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<circle cx="6" cy="16.5" r="1.2" fill="currentColor" stroke="none"/>
|
||||
<line x1="10" y1="6.5" x2="16" y2="6.5"/>
|
||||
<line x1="10" y1="16.5" x2="16" y2="16.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Features / Plugin -->
|
||||
<symbol id="ni-features" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H9.5a1 1 0 0 0-.9.5l-4 7a1 1 0 0 0 0 1l4 7a1 1 0 0 0 .9.5h5a1 1 0 0 0 .9-.5l4-7a1 1 0 0 0 0-1l-4-7a1 1 0 0 0-.9-.5z"/>
|
||||
<circle cx="12" cy="12" r="2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Updates / Git -->
|
||||
<symbol id="ni-updates" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-3.51"/>
|
||||
<polyline points="12 7 12 12 15 15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Firewall / Shield -->
|
||||
<symbol id="ni-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="15" x2="12.01" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Backups -->
|
||||
<symbol id="ni-backups" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="21 15 21 21 15 21"/>
|
||||
<polyline points="3 9 3 3 9 3"/>
|
||||
<path d="M21 3L14 10"/>
|
||||
<path d="M3 21l7-7"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Settings / Gear -->
|
||||
<symbol id="ni-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3.5"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Audit Log -->
|
||||
<symbol id="ni-audit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="12" y2="17"/>
|
||||
<circle cx="16" cy="17" r="2"/>
|
||||
<line x1="18" y1="19" x2="20" y2="21"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Webmail -->
|
||||
<symbol id="ni-webmail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10z"/>
|
||||
<path d="M12 8a4 4 0 0 1 0 8 4 4 0 0 1 0-8z"/>
|
||||
<path d="M16 12h6M2 12h6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- WordPress -->
|
||||
<symbol id="ni-wordpress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2.48 12s3.22 7 9.52 7 9.52-7 9.52-7"/>
|
||||
<path d="M12 2c2.8 2 5 5.8 5 10s-2.2 8-5 10"/>
|
||||
<path d="M12 2c-2.8 2-5 5.8-5 10s2.2 8 5 10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docker -->
|
||||
<symbol id="ni-docker" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="12" y="9" width="4" height="4" rx="0.5"/>
|
||||
<rect x="7" y="4" width="4" height="4" rx="0.5"/>
|
||||
<path d="M18 11a4 4 0 0 1 4 4c0 3-3 5-7 5H6c-2 0-4-1.5-4-4 0-1.8 1-3.5 3-4.5"/>
|
||||
<path d="M20 7s-1-1-3-1"/>
|
||||
<circle cx="20" cy="6" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Node.js -->
|
||||
<symbol id="ni-nodejs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12,2 22,7.5 22,16.5 12,22 2,16.5 2,7.5"/>
|
||||
<path d="M12 7v5l4 2.5"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Redis -->
|
||||
<symbol id="ni-redis" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="8" rx="9" ry="3"/>
|
||||
<path d="M3 8v4c0 1.66 4.03 3 9 3s9-1.34 9-3V8"/>
|
||||
<path d="M3 12v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/>
|
||||
<path d="M8 6l2 1 4-2"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Cloudflare -->
|
||||
<symbol id="ni-cloudflare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.5 16c.83-1.5.5-4-2.5-4.5l.3-1.5C18 9.5 21 11 21 14.5c0 1-.2 2-.8 2.5H17.5z"/>
|
||||
<path d="M6.5 16H17m-3-4.5C12.5 8 8 8 6 10.5c-1 1.5-1 3-0.5 4.5"/>
|
||||
<path d="M3 15c0 .55.45 1 1 1h1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Gitea -->
|
||||
<symbol id="ni-git" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3"/>
|
||||
<circle cx="6" cy="6" r="3"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<path d="M6 9v6"/>
|
||||
<path d="M15.7 5.7l-9.4 12.6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Suspend / pause -->
|
||||
<symbol id="ni-suspend" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="10" y1="8" x2="10" y2="16"/>
|
||||
<line x1="14" y1="8" x2="14" y2="16"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Terminate / X -->
|
||||
<symbol id="ni-terminate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Stats / Chart -->
|
||||
<symbol id="ni-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Notifications / bell -->
|
||||
<symbol id="ni-notifications" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Hostname -->
|
||||
<symbol id="ni-hostname" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Add / Plus -->
|
||||
<symbol id="ni-add" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- User profile -->
|
||||
<symbol id="ni-profile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Logout -->
|
||||
<symbol id="ni-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Search -->
|
||||
<symbol id="ni-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Copy -->
|
||||
<symbol id="ni-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Key / API -->
|
||||
<symbol id="ni-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Mail server -->
|
||||
<symbol id="ni-mailserver" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2"/>
|
||||
<path d="M22 6l-10 7L2 6"/>
|
||||
<path d="M2 6l4-4h12l4 4"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Docs / book -->
|
||||
<symbol id="ni-docs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
<line x1="8" y1="7" x2="16" y2="7"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Contact / support -->
|
||||
<symbol id="ni-support" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="10" x2="9.01" y2="10"/>
|
||||
<line x1="12" y1="10" x2="12.01" y2="10"/>
|
||||
<line x1="15" y1="10" x2="15.01" y2="10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- ModSecurity / WAF -->
|
||||
<symbol id="ni-waf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 11 14 15 10"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Varnish / cache -->
|
||||
<symbol id="ni-cache" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Network / IP -->
|
||||
<symbol id="ni-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="2" width="6" height="4" rx="1"/>
|
||||
<rect x="2" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="16" y="18" width="6" height="4" rx="1"/>
|
||||
<rect x="9" y="18" width="6" height="4" rx="1"/>
|
||||
<line x1="12" y1="6" x2="12" y2="14"/>
|
||||
<path d="M5 20v-6h14v6"/>
|
||||
<line x1="12" y1="14" x2="5" y2="14"/>
|
||||
<line x1="12" y1="14" x2="19" y2="14"/>
|
||||
</symbol>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48">
|
||||
<defs>
|
||||
<linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ng2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Hexagon core mark -->
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#ng)" stroke-width="2.5"/>
|
||||
<!-- Inner orbit ring -->
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#ng)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<!-- Central dot -->
|
||||
<circle cx="24" cy="22" r="3" fill="url(#ng)"/>
|
||||
<!-- Orbit node dots -->
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="52" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="700" fill="url(#ng)" letter-spacing="-0.5">Nova</text>
|
||||
<text x="118" y="32" font-family="'SF Pro Display','Segoe UI',sans-serif" font-size="22" font-weight="300" fill="#94a3b8" letter-spacing="2">CPX</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="mg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="60%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#10b981"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="24,4 40,13 40,31 24,40 8,31 8,13" fill="none" stroke="url(#mg)" stroke-width="2.5"/>
|
||||
<circle cx="24" cy="22" r="7" fill="none" stroke="url(#mg)" stroke-width="1.5" stroke-dasharray="3 2"/>
|
||||
<circle cx="24" cy="22" r="3" fill="url(#mg)"/>
|
||||
<circle cx="24" cy="14" r="1.5" fill="#6366f1"/>
|
||||
<circle cx="31" cy="26" r="1.5" fill="#0ea5e9"/>
|
||||
<circle cx="17" cy="26" r="1.5" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
Reference in New Issue
Block a user