diff --git a/db/migrations/008_branding_audit.sql b/db/migrations/008_branding_audit.sql
new file mode 100644
index 0000000..c443a9e
--- /dev/null
+++ b/db/migrations/008_branding_audit.sql
@@ -0,0 +1,16 @@
+-- Migration 008: Reseller branding table
+
+CREATE TABLE IF NOT EXISTS reseller_branding (
+ user_id INT UNSIGNED PRIMARY KEY,
+ panel_name VARCHAR(100) NOT NULL DEFAULT 'NovaCPX',
+ logo_url VARCHAR(500) DEFAULT NULL,
+ favicon_url VARCHAR(500) DEFAULT NULL,
+ primary_color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
+ accent_color VARCHAR(20) NOT NULL DEFAULT '#0ea5e9',
+ support_email VARCHAR(255) DEFAULT NULL,
+ support_url VARCHAR(500) DEFAULT NULL,
+ hide_powered_by TINYINT(1) NOT NULL DEFAULT 0,
+ custom_css TEXT DEFAULT NULL,
+ updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_branding_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/panel/api/endpoints/branding.php b/panel/api/endpoints/branding.php
new file mode 100644
index 0000000..a503c37
--- /dev/null
+++ b/panel/api/endpoints/branding.php
@@ -0,0 +1,113 @@
+user();
+
+// Resolve which reseller's branding we're working with
+if ($user['role'] === 'admin') {
+ $resellerId = (int)($body['reseller_id'] ?? $_GET['reseller_id'] ?? 0);
+} elseif ($user['role'] === 'reseller') {
+ $resellerId = $user['uid'];
+} else {
+ Response::error('Forbidden', 403);
+}
+
+match ($action) {
+
+ 'get' => (function() use ($db, $resellerId) {
+ if (!$resellerId) Response::error('reseller_id required');
+ $row = $db->fetchOne("SELECT * FROM reseller_branding WHERE user_id = ?", [$resellerId]);
+ Response::success($row ?: ['user_id' => $resellerId]);
+ })(),
+
+ 'save' => (function() use ($db, $body, $resellerId, $user) {
+ if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403);
+ if (!$resellerId) Response::error('reseller_id required');
+
+ $allowed = ['panel_name','logo_url','favicon_url','primary_color','accent_color',
+ 'support_email','support_url','hide_powered_by','custom_css'];
+ $fields = [];
+ $vals = [];
+ foreach ($allowed as $k) {
+ if (array_key_exists($k, $body)) {
+ $fields[] = "`$k`";
+ $vals[] = $body[$k];
+ }
+ }
+ if (!$fields) Response::error('No fields to update');
+
+ // Validate colors
+ foreach (['primary_color','accent_color'] as $c) {
+ if (isset($body[$c]) && !preg_match('/^#[0-9a-fA-F]{3,6}$/', $body[$c])) {
+ Response::error("Invalid color value for $c");
+ }
+ }
+
+ $placeholders = implode(',', array_fill(0, count($fields), '?'));
+ $setClauses = implode(', ', array_map(fn($f) => "$f = ?", $fields));
+ $db->execute(
+ "INSERT INTO reseller_branding (user_id, " . implode(', ', $fields) . ")
+ VALUES (?, $placeholders)
+ ON DUPLICATE KEY UPDATE $setClauses",
+ array_merge([$resellerId], $vals, $vals)
+ );
+ audit('branding.save', "reseller:$resellerId");
+ Response::success(null, 'Branding saved');
+ })(),
+
+ 'upload-logo' => (function() use ($resellerId, $user) {
+ if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403);
+ if (!$resellerId) Response::error('reseller_id required');
+
+ $file = $_FILES['logo'] ?? null;
+ if (!$file || $file['error'] !== UPLOAD_ERR_OK) Response::error('File upload failed');
+
+ $allowed = ['image/png','image/jpeg','image/gif','image/svg+xml','image/webp'];
+ $finfo = new finfo(FILEINFO_MIME_TYPE);
+ $mime = $finfo->file($file['tmp_name']);
+ if (!in_array($mime, $allowed)) Response::error('Invalid file type. Allowed: PNG, JPG, GIF, SVG, WebP');
+ if ($file['size'] > 512 * 1024) Response::error('Logo must be under 512 KB');
+
+ $ext = ['image/png'=>'png','image/jpeg'=>'jpg','image/gif'=>'gif',
+ 'image/svg+xml'=>'svg','image/webp'=>'webp'][$mime] ?? 'png';
+ $dir = '/srv/novacpx/public/uploads/branding/' . $resellerId;
+ if (!is_dir($dir)) mkdir($dir, 0755, true);
+
+ $path = "$dir/logo.$ext";
+ // Remove old logo files
+ foreach (glob("$dir/logo.*") as $old) @unlink($old);
+ if (!move_uploaded_file($file['tmp_name'], $path)) Response::error('Failed to save logo');
+
+ $url = "/uploads/branding/{$resellerId}/logo.$ext";
+ DB::getInstance()->execute(
+ "INSERT INTO reseller_branding (user_id, logo_url) VALUES (?,?)
+ ON DUPLICATE KEY UPDATE logo_url = VALUES(logo_url)",
+ [$resellerId, $url]
+ );
+ audit('branding.upload-logo', "reseller:$resellerId");
+ Response::success(['url' => $url], 'Logo uploaded');
+ })(),
+
+ 'delete-logo' => (function() use ($db, $resellerId, $user) {
+ if ($user['role'] !== 'admin' && $user['role'] !== 'reseller') Response::error('Forbidden', 403);
+ $dir = '/srv/novacpx/public/uploads/branding/' . $resellerId;
+ foreach (glob("$dir/logo.*") ?: [] as $f) @unlink($f);
+ $db->execute("UPDATE reseller_branding SET logo_url = NULL WHERE user_id = ?", [$resellerId]);
+ Response::success(null, 'Logo removed');
+ })(),
+
+ 'resellers' => (function() use ($db, $user) {
+ Auth::getInstance()->require('admin');
+ $rows = $db->fetchAll(
+ "SELECT u.id, u.username, u.email, b.panel_name, b.logo_url, b.primary_color
+ FROM users u LEFT JOIN reseller_branding b ON b.user_id = u.id
+ WHERE u.role = 'reseller' ORDER BY u.username"
+ );
+ Response::success($rows);
+ })(),
+
+ default => Response::error("Unknown branding action: $action", 404),
+};
diff --git a/panel/api/endpoints/system.php b/panel/api/endpoints/system.php
index ec05830..27233c1 100644
--- a/panel/api/endpoints/system.php
+++ b/panel/api/endpoints/system.php
@@ -341,11 +341,27 @@ match ($action) {
// ── Audit log ─────────────────────────────────────────────────────────────
'audit-log' => (function() use ($db) {
Auth::getInstance()->require('admin');
- $page = max(1, (int)($_GET['page'] ?? 1));
- $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50)));
- $offset = ($page - 1) * $perPage;
- $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log")['c'] ?? 0;
- $rows = $db->fetchAll("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset]);
+ $page = max(1, (int)($_GET['page'] ?? 1));
+ $perPage = min(100, max(10, (int)($_GET['per_page'] ?? 50)));
+ $offset = ($page - 1) * $perPage;
+ $user = trim($_GET['user'] ?? '');
+ $action = trim($_GET['action'] ?? '');
+ $dateFrom = trim($_GET['date_from'] ?? '');
+ $dateTo = trim($_GET['date_to'] ?? '');
+
+ $where = 'WHERE 1=1';
+ $params = [];
+ if ($user) { $where .= ' AND username LIKE ?'; $params[] = "%$user%"; }
+ if ($action) { $where .= ' AND action LIKE ?'; $params[] = "%$action%"; }
+ if ($dateFrom) { $where .= ' AND created_at >= ?'; $params[] = $dateFrom . ' 00:00:00'; }
+ if ($dateTo) { $where .= ' AND created_at <= ?'; $params[] = $dateTo . ' 23:59:59'; }
+
+ $total = $db->fetchOne("SELECT COUNT(*) as c FROM audit_log $where", $params)['c'] ?? 0;
+ $rows = $db->fetchAll(
+ "SELECT id, user_id, username, action, resource, ip_address, detail, created_at
+ FROM audit_log $where ORDER BY created_at DESC LIMIT ? OFFSET ?",
+ [...$params, $perPage, $offset]
+ );
Response::paginate($rows, (int)$total, $page, $perPage);
})(),
diff --git a/panel/public/_branding.php b/panel/public/_branding.php
new file mode 100644
index 0000000..cab935d
--- /dev/null
+++ b/panel/public/_branding.php
@@ -0,0 +1,80 @@
+ before JS loads.
+ * Reads session cookie → looks up user's reseller → returns branding row.
+ */
+function novacpx_get_branding(): array {
+ static $cache = null;
+ if ($cache !== null) return $cache;
+ $cfg = @parse_ini_file('/etc/novacpx/config.ini', true);
+ if (!$cfg) return $cache = [];
+ try {
+ $pdo = new PDO(
+ "mysql:host={$cfg['database']['host']};dbname={$cfg['database']['name']};charset=utf8mb4",
+ $cfg['database']['user'], $cfg['database']['pass'],
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+ $token = $_COOKIE['ncpx_session'] ?? '';
+ if (!$token || strlen($token) < 32) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE token = ? AND expires_at > NOW() LIMIT 1");
+ $stmt->execute([substr($token, 0, 128)]);
+ $uid = (int)($stmt->fetchColumn() ?: 0);
+ if (!$uid) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1");
+ $stmt->execute([$uid]);
+ $u = $stmt->fetch();
+ if (!$u) return $cache = [];
+
+ $resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0);
+ if (!$resellerId) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1");
+ $stmt->execute([$resellerId]);
+ $row = $stmt->fetch();
+ return $cache = ($row ?: []);
+ } catch (Throwable $e) {
+ return $cache = [];
+ }
+}
+
+function novacpx_branding_head(): void {
+ $b = novacpx_get_branding();
+ if (!$b) return;
+ $pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null;
+ $ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null;
+ $css = $b['custom_css'] ?? '';
+ echo ' tags
+ echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n";
+ echo '' . "\n";
+ if ($b['favicon_url'] ?? '') {
+ $fav = htmlspecialchars($b['favicon_url']);
+ echo "\n";
+ }
+}
+
+function novacpx_panel_name(string $default): string {
+ $b = novacpx_get_branding();
+ return htmlspecialchars($b['panel_name'] ?? $default);
+}
+
+function novacpx_logo_html(string $default_svg): string {
+ $b = novacpx_get_branding();
+ if (!empty($b['logo_url'])) {
+ $url = htmlspecialchars($b['logo_url']);
+ $name = htmlspecialchars($b['panel_name'] ?? 'Panel');
+ return "";
+ }
+ return $default_svg;
+}
+
+function novacpx_powered_by(): bool {
+ $b = novacpx_get_branding();
+ return empty($b['hide_powered_by']);
+}
diff --git a/panel/public/assets/js/admin.js b/panel/public/assets/js/admin.js
index 43815d7..078270d 100644
--- a/panel/public/assets/js/admin.js
+++ b/panel/public/assets/js/admin.js
@@ -350,28 +350,109 @@
}
// ── Audit Log ──────────────────────────────────────────────────────────────
- async function auditLog() {
- const res = await Nova.api('system', 'audit-log', { params: { per_page: 50 } });
- const rows = res?.data || [];
- return `
-
| Time | User | Action | Resource | IP |
|---|---|---|---|---|
| ${Nova.relTime(r.created_at)} | -${r.username || '—'} | -${r.action} |
- ${r.resource || '—'} | -${r.ip_address || '—'} | -
| Time | User | Action | Resource | IP | |
|---|---|---|---|---|---|
| ${Nova.relTime(r.created_at)} | +${Nova.escHtml(r.username || '—')} | +${Nova.escHtml(r.action)} |
+ ${Nova.escHtml(r.resource || '—')} | +${Nova.escHtml(r.ip_address || '—')} | +▾ | +
No audit entries match the current filters.