mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
#18: reseller_branding table (migration 008). branding.php endpoint: get/save/ upload-logo/delete-logo/resellers. _branding.php server-side helper injects CSS vars (--primary, --accent), custom CSS, favicon, and panel name into <head> of reseller + user portals at page-load time (no flash of unbranded content). NOVACPX_BRANDING JS global carries panel_name/support_email/ support_url/hide_powered_by for runtime use. Reseller panel gets a new "White Label" sidebar page with logo upload, color pickers with live preview, support contact fields, powered-by toggle, and custom CSS textarea. #24: audit-log backend now accepts user/action/date_from/date_to filter params. auditLog() JS rebuilt: filter bar at top, paginated table, expandable detail rows (click row to show JSON detail), total entry count, page buttons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Branding endpoint — reseller white-label settings
|
||||
*/
|
||||
$db = DB::getInstance();
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$user = Auth::getInstance()->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),
|
||||
};
|
||||
@@ -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);
|
||||
})(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user