view = new View(__DIR__ . '/views'); } public function index(): Response { if (!$this->dbReady()) { return $this->installer(); } $this->ensureCoreTables(); if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } return new Response($this->view->render('dashboard.php', [ 'title' => 'Admin', ])); } public function installer(): Response { $installer = $_SESSION['installer'] ?? []; $step = !empty($installer['core_ready']) ? 2 : 1; $values = is_array($installer['values'] ?? null) ? $installer['values'] : []; $smtpResult = is_array($installer['smtp_result'] ?? null) ? $installer['smtp_result'] : []; $checks = is_array($installer['checks'] ?? null) ? $installer['checks'] : []; return new Response($this->view->render('installer.php', [ 'title' => 'Installer', 'step' => $step, 'error' => (string)($_GET['error'] ?? ''), 'success' => (string)($_GET['success'] ?? ''), 'values' => $values, 'smtp_result' => $smtpResult, 'checks' => $checks, ])); } public function install(): Response { $action = trim((string)($_POST['installer_action'] ?? 'setup_core')); if ($action === 'setup_core') { return $this->installSetupCore(); } if ($action === 'test_smtp') { return $this->installTestSmtp(); } if ($action === 'finish_install') { return $this->installFinish(); } return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Invalid installer action.')]); } public function loginForm(): Response { if (!$this->dbReady()) { return $this->installer(); } $this->ensureCoreTables(); return new Response($this->view->render('login.php', [ 'title' => 'Admin Login', 'error' => '', ])); } public function login(): Response { $this->ensureCoreTables(); $email = trim((string)($_POST['email'] ?? '')); $password = (string)($_POST['password'] ?? ''); $db = Database::get(); if (!$db instanceof PDO) { return new Response($this->view->render('login.php', [ 'title' => 'Admin Login', 'error' => 'Database unavailable.', ])); } try { $stmt = $db->prepare("SELECT id, name, password_hash, role FROM ac_admin_users WHERE email = :email LIMIT 1"); $stmt->execute([':email' => $email]); $row = $stmt->fetch(); if ($row && password_verify($password, (string)$row['password_hash'])) { Auth::login((int)$row['id'], (string)($row['role'] ?? 'admin'), (string)($row['name'] ?? 'Admin')); return new Response('', 302, ['Location' => '/admin']); } $stmt = $db->prepare("SELECT id, name, password_hash FROM ac_admins WHERE email = :email LIMIT 1"); $stmt->execute([':email' => $email]); $row = $stmt->fetch(); if ($row && password_verify($password, (string)$row['password_hash'])) { Auth::login((int)$row['id'], 'admin', (string)($row['name'] ?? 'Admin')); return new Response('', 302, ['Location' => '/admin']); } } catch (Throwable $e) { return new Response($this->view->render('login.php', [ 'title' => 'Admin Login', 'error' => 'Login failed due to missing database tables. Open /admin once to initialize tables, then retry.', ])); } return new Response($this->view->render('login.php', [ 'title' => 'Admin Login', 'error' => 'Invalid login.', ])); } public function logout(): Response { Auth::logout(); return new Response('', 302, ['Location' => '/admin/login']); } public function accountsIndex(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $db = Database::get(); $users = []; $error = ''; if ($db instanceof PDO) { try { $stmt = $db->query("SELECT id, name, email, role, created_at FROM ac_admin_users ORDER BY created_at DESC"); $users = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; } catch (Throwable $e) { $error = 'Accounts table not available.'; } } else { $error = 'Database unavailable.'; } return new Response($this->view->render('accounts.php', [ 'title' => 'Accounts', 'users' => $users, 'error' => $error, ])); } public function accountsNew(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } return new Response($this->view->render('account_new.php', [ 'title' => 'New Account', 'error' => '', ])); } public function accountsSave(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $name = trim((string)($_POST['name'] ?? '')); $email = trim((string)($_POST['email'] ?? '')); $password = (string)($_POST['password'] ?? ''); $role = trim((string)($_POST['role'] ?? 'editor')); if ($name === '' || $email === '' || $password === '') { return new Response($this->view->render('account_new.php', [ 'title' => 'New Account', 'error' => 'Name, email, and password are required.', ])); } if (!in_array($role, ['admin', 'manager', 'editor'], true)) { $role = 'editor'; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/accounts']); } try { $hash = password_hash($password, PASSWORD_DEFAULT); $stmt = $db->prepare(" INSERT INTO ac_admin_users (name, email, password_hash, role) VALUES (:name, :email, :hash, :role) "); $stmt->execute([ ':name' => $name, ':email' => $email, ':hash' => $hash, ':role' => $role, ]); } catch (Throwable $e) { return new Response($this->view->render('account_new.php', [ 'title' => 'New Account', 'error' => 'Unable to create account (email may exist).', ])); } return new Response('', 302, ['Location' => '/admin/accounts']); } public function accountsDelete(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $id = (int)($_POST['id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $id > 0) { $stmt = $db->prepare("DELETE FROM ac_admin_users WHERE id = :id"); $stmt->execute([':id' => $id]); } return new Response('', 302, ['Location' => '/admin/accounts']); } public function updatesForm(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $force = ((string)($_GET['force'] ?? '') === '1'); $status = Updater::getStatus($force); return new Response($this->view->render('updates.php', [ 'title' => 'Updates', 'status' => $status, 'channel' => Settings::get('update_channel', 'stable'), 'message' => trim((string)($_GET['message'] ?? '')), 'message_type' => trim((string)($_GET['message_type'] ?? '')), ])); } public function updatesSave(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $action = trim((string)($_POST['updates_action'] ?? '')); if ($action === 'save_config') { $channel = trim((string)($_POST['update_channel'] ?? 'stable')); if (!in_array($channel, ['stable', 'beta'], true)) { $channel = 'stable'; } Settings::set('update_channel', $channel); Audit::log('updates.config.save', [ 'channel' => $channel, ]); return new Response('', 302, ['Location' => '/admin/updates?message=' . rawurlencode('Update settings saved.') . '&message_type=ok']); } if ($action === 'check_now') { Updater::getStatus(true); Audit::log('updates.check.now'); return new Response('', 302, ['Location' => '/admin/updates?force=1&message=' . rawurlencode('Update check complete.') . '&message_type=ok']); } return new Response('', 302, ['Location' => '/admin/updates?message=' . rawurlencode('Unknown action.') . '&message_type=error']); } public function settingsForm(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $this->ensureCoreTables(); $this->ensureSettingsAuxTables(); $status = trim((string)($_GET['status'] ?? '')); $statusMessage = trim((string)($_GET['message'] ?? '')); $db = Database::get(); $redirects = []; if ($db instanceof PDO) { try { $stmt = $db->query(" SELECT id, source_path, target_url, status_code, is_active, updated_at FROM ac_redirects ORDER BY source_path ASC "); $redirects = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; } catch (Throwable $e) { $redirects = []; } } return new Response($this->view->render('settings.php', [ 'title' => 'Settings', 'status' => $status, 'status_message' => $statusMessage, 'footer_text' => Settings::get('footer_text', 'AudioCore V1.5'), 'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')), 'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5'), 'site_header_tagline' => Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'), 'site_header_badge_text' => Settings::get('site_header_badge_text', 'Independent catalog'), 'site_header_brand_mode' => Settings::get('site_header_brand_mode', 'default'), 'site_header_mark_mode' => Settings::get('site_header_mark_mode', 'text'), 'site_header_mark_text' => Settings::get('site_header_mark_text', 'AC'), 'site_header_mark_icon' => Settings::get('site_header_mark_icon', 'fa-solid fa-music'), 'site_header_mark_bg_start' => Settings::get('site_header_mark_bg_start', '#22f2a5'), 'site_header_mark_bg_end' => Settings::get('site_header_mark_bg_end', '#10252e'), 'site_header_logo_url' => Settings::get('site_header_logo_url', ''), 'fontawesome_url' => Settings::get('fontawesome_url', ''), 'fontawesome_pro_url' => Settings::get('fontawesome_pro_url', ''), 'site_maintenance_enabled' => Settings::get('site_maintenance_enabled', '0'), 'site_maintenance_title' => Settings::get('site_maintenance_title', 'Coming Soon'), 'site_maintenance_message' => Settings::get('site_maintenance_message', 'We are currently updating the site. Please check back soon.'), 'site_maintenance_button_label' => Settings::get('site_maintenance_button_label', ''), 'site_maintenance_button_url' => Settings::get('site_maintenance_button_url', ''), 'site_maintenance_html' => Settings::get('site_maintenance_html', ''), 'smtp_host' => Settings::get('smtp_host', ''), 'smtp_port' => Settings::get('smtp_port', '587'), 'smtp_user' => Settings::get('smtp_user', ''), 'smtp_pass' => Settings::get('smtp_pass', ''), 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), 'smtp_from_email' => Settings::get('smtp_from_email', ''), 'smtp_from_name' => Settings::get('smtp_from_name', ''), 'mailchimp_api_key' => Settings::get('mailchimp_api_key', ''), 'mailchimp_list_id' => Settings::get('mailchimp_list_id', ''), 'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5')), 'seo_meta_description' => Settings::get('seo_meta_description', ''), 'seo_robots_index' => Settings::get('seo_robots_index', '1'), 'seo_robots_follow' => Settings::get('seo_robots_follow', '1'), 'seo_og_image' => Settings::get('seo_og_image', ''), 'redirects' => $redirects, 'permission_definitions' => Permissions::definitions(), 'permission_matrix' => Permissions::matrix(), 'audit_logs' => Audit::latest(120), ])); } public function shortcodesIndex(): Response { if ($guard = $this->guard(['admin', 'manager', 'editor'])) { return $guard; } Plugins::sync(); $codes = [ [ 'tag' => '[releases]', 'description' => 'Outputs the releases grid.', 'example' => '[releases limit="8"]', 'source' => 'Releases plugin', 'enabled' => Plugins::isEnabled('releases'), ], [ 'tag' => '[sale-chart]', 'description' => 'Outputs a best-sellers chart.', 'example' => '[sale-chart limit="10"]', 'source' => 'Store plugin', 'enabled' => Plugins::isEnabled('store'), ], [ 'tag' => '[login-link]', 'description' => 'Renders an account login link.', 'example' => '[login-link label="Login"]', 'source' => 'Store plugin', 'enabled' => Plugins::isEnabled('store'), ], [ 'tag' => '[account-link]', 'description' => 'Renders a my account link.', 'example' => '[account-link label="My Account"]', 'source' => 'Store plugin', 'enabled' => Plugins::isEnabled('store'), ], [ 'tag' => '[cart-link]', 'description' => 'Renders a cart link with count/total.', 'example' => '[cart-link label="Cart" show_count="1" show_total="1"]', 'source' => 'Store plugin', 'enabled' => Plugins::isEnabled('store'), ], [ 'tag' => '[checkout-link]', 'description' => 'Renders a checkout link.', 'example' => '[checkout-link label="Checkout"]', 'source' => 'Store plugin', 'enabled' => Plugins::isEnabled('store'), ], [ 'tag' => '[newsletter-signup]', 'description' => 'Renders newsletter signup form.', 'example' => '[newsletter-signup title="Join the list" button="Subscribe"]', 'source' => 'Newsletter module', 'enabled' => true, ], [ 'tag' => '[newsletter-unsubscribe]', 'description' => 'Renders unsubscribe link.', 'example' => '[newsletter-unsubscribe label="Unsubscribe"]', 'source' => 'Newsletter module', 'enabled' => true, ], [ 'tag' => '[newsletter-unsubscribe-form]', 'description' => 'Renders unsubscribe by email form.', 'example' => '[newsletter-unsubscribe-form title="Leave list"]', 'source' => 'Newsletter module', 'enabled' => true, ], [ 'tag' => '[support-link]', 'description' => 'Renders support/contact link.', 'example' => '[support-link label="Support"]', 'source' => 'Support plugin', 'enabled' => Plugins::isEnabled('support'), ], ]; $storeChartKey = trim((string)Settings::get('store_sales_chart_cron_key', '')); if ($storeChartKey === '' && Plugins::isEnabled('store')) { try { $storeChartKey = bin2hex(random_bytes(24)); Settings::set('store_sales_chart_cron_key', $storeChartKey); } catch (Throwable $e) { $storeChartKey = ''; } } $baseUrl = $this->baseUrl(); $storeCronUrl = ($baseUrl !== '' && $storeChartKey !== '') ? $baseUrl . '/store/sales-chart/rebuild?key=' . rawurlencode($storeChartKey) : ''; $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180'))); $step = max(1, (int)floor($minutes / 60)); $storeCronExpr = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *'; $storeCronCmd = $storeCronUrl !== '' ? $storeCronExpr . " /usr/bin/curl -fsS '" . $storeCronUrl . "' >/dev/null 2>&1" : ''; return new Response($this->view->render('shortcodes.php', [ 'title' => 'Shortcodes', 'codes' => $codes, 'sale_chart_cron_url' => $storeCronUrl, 'sale_chart_cron_cmd' => $storeCronCmd, 'sale_chart_cron_enabled' => Plugins::isEnabled('store'), ])); } public function shortcodesPreview(): Response { if ($guard = $this->guard(['admin', 'manager', 'editor'])) { return $guard; } $code = trim((string)($_GET['code'] ?? '')); if ($code === '') { return new Response('

No shortcode supplied.

', 400); } $allowedTags = [ 'releases', 'sale-chart', 'login-link', 'account-link', 'cart-link', 'checkout-link', 'newsletter-signup', 'newsletter-unsubscribe', 'newsletter-unsubscribe-form', 'support-link', ]; $tag = ''; if (preg_match('/^\[\s*([a-zA-Z0-9_-]+)/', $code, $m)) { $tag = strtolower((string)$m[1]); } $isAllowed = in_array($tag, $allowedTags, true); if (!$isAllowed) { return new Response('

Shortcode preview not allowed.

', 403); } $rendered = Shortcodes::render($code, ['preview' => true]); $html = '' . 'Shortcode Preview' . '' . '' . '' . '
' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '
' . $rendered . '
'; return new Response($html); } public function saveSettings(): Response { if ($guard = $this->guard(['admin'])) { return $guard; } $this->ensureCoreTables(); $this->ensureSettingsAuxTables(); $action = trim((string)($_POST['settings_action'] ?? '')); if ($action === 'upload_logo') { return $this->uploadHeaderLogo(); } if ($action === 'remove_logo') { Settings::set('site_header_logo_url', ''); Audit::log('settings.logo.remove'); return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Logo removed.')]); } if ($action === 'save_redirect') { return $this->saveRedirect(); } if ($action === 'delete_redirect') { return $this->deleteRedirect(); } if ($action === 'save_permissions') { Permissions::saveMatrix((array)($_POST['permissions'] ?? [])); Audit::log('settings.permissions.save'); return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Role permissions updated.')]); } $footer = trim((string)($_POST['footer_text'] ?? '')); $footerLinksJson = (string)($_POST['footer_links_json'] ?? '[]'); $siteHeaderTitle = trim((string)($_POST['site_header_title'] ?? '')); $siteHeaderTagline = trim((string)($_POST['site_header_tagline'] ?? '')); $siteHeaderBadgeText = trim((string)($_POST['site_header_badge_text'] ?? '')); $siteHeaderBrandMode = trim((string)($_POST['site_header_brand_mode'] ?? 'default')); $siteHeaderMarkMode = trim((string)($_POST['site_header_mark_mode'] ?? 'text')); $siteHeaderMarkText = trim((string)($_POST['site_header_mark_text'] ?? '')); $siteHeaderMarkIcon = trim((string)($_POST['site_header_mark_icon'] ?? '')); $siteHeaderMarkBgStart = trim((string)($_POST['site_header_mark_bg_start'] ?? '')); $siteHeaderMarkBgEnd = trim((string)($_POST['site_header_mark_bg_end'] ?? '')); $siteHeaderLogoUrl = trim((string)($_POST['site_header_logo_url'] ?? '')); $faUrl = trim((string)($_POST['fontawesome_url'] ?? '')); $faProUrl = trim((string)($_POST['fontawesome_pro_url'] ?? '')); $maintenanceEnabled = isset($_POST['site_maintenance_enabled']) ? '1' : '0'; $maintenanceTitle = trim((string)($_POST['site_maintenance_title'] ?? '')); $maintenanceMessage = trim((string)($_POST['site_maintenance_message'] ?? '')); $maintenanceButtonLabel = trim((string)($_POST['site_maintenance_button_label'] ?? '')); $maintenanceButtonUrl = trim((string)($_POST['site_maintenance_button_url'] ?? '')); $maintenanceHtml = trim((string)($_POST['site_maintenance_html'] ?? '')); $smtpHost = trim((string)($_POST['smtp_host'] ?? '')); $smtpPort = trim((string)($_POST['smtp_port'] ?? '')); $smtpUser = trim((string)($_POST['smtp_user'] ?? '')); $smtpPass = trim((string)($_POST['smtp_pass'] ?? '')); $smtpEncryption = trim((string)($_POST['smtp_encryption'] ?? '')); $smtpFromEmail = trim((string)($_POST['smtp_from_email'] ?? '')); $smtpFromName = trim((string)($_POST['smtp_from_name'] ?? '')); $mailchimpKey = trim((string)($_POST['mailchimp_api_key'] ?? '')); $mailchimpList = trim((string)($_POST['mailchimp_list_id'] ?? '')); $seoTitleSuffix = trim((string)($_POST['seo_title_suffix'] ?? '')); $seoMetaDescription = trim((string)($_POST['seo_meta_description'] ?? '')); $seoRobotsIndex = isset($_POST['seo_robots_index']) ? '1' : '0'; $seoRobotsFollow = isset($_POST['seo_robots_follow']) ? '1' : '0'; $seoOgImage = trim((string)($_POST['seo_og_image'] ?? '')); Settings::set('footer_text', $footer); $footerLinks = $this->parseFooterLinks($footerLinksJson); Settings::set('footer_links_json', json_encode($footerLinks, JSON_UNESCAPED_SLASHES)); Settings::set('site_header_title', $siteHeaderTitle); Settings::set('site_header_tagline', $siteHeaderTagline); Settings::set('site_header_badge_text', $siteHeaderBadgeText); Settings::set('site_header_brand_mode', in_array($siteHeaderBrandMode, ['default', 'logo_only'], true) ? $siteHeaderBrandMode : 'default'); Settings::set('site_header_mark_mode', in_array($siteHeaderMarkMode, ['text', 'icon', 'logo'], true) ? $siteHeaderMarkMode : 'text'); Settings::set('site_header_mark_text', $siteHeaderMarkText); if ($siteHeaderMarkIcon !== '') { if (preg_match('/class\\s*=\\s*"([^"]+)"/i', $siteHeaderMarkIcon, $m)) { $siteHeaderMarkIcon = trim((string)$m[1]); } $siteHeaderMarkIcon = trim(strip_tags($siteHeaderMarkIcon)); } Settings::set('site_header_mark_icon', $siteHeaderMarkIcon); Settings::set('site_header_mark_bg_start', $siteHeaderMarkBgStart); Settings::set('site_header_mark_bg_end', $siteHeaderMarkBgEnd); Settings::set('site_header_logo_url', $siteHeaderLogoUrl); Settings::set('fontawesome_url', $faUrl); Settings::set('fontawesome_pro_url', $faProUrl); Settings::set('site_maintenance_enabled', $maintenanceEnabled); Settings::set('site_maintenance_title', $maintenanceTitle); Settings::set('site_maintenance_message', $maintenanceMessage); Settings::set('site_maintenance_button_label', $maintenanceButtonLabel); Settings::set('site_maintenance_button_url', $maintenanceButtonUrl); Settings::set('site_maintenance_html', $maintenanceHtml); Settings::set('smtp_host', $smtpHost); Settings::set('smtp_port', $smtpPort); Settings::set('smtp_user', $smtpUser); Settings::set('smtp_pass', $smtpPass); Settings::set('smtp_encryption', $smtpEncryption); Settings::set('smtp_from_email', $smtpFromEmail); Settings::set('smtp_from_name', $smtpFromName); Settings::set('mailchimp_api_key', $mailchimpKey); Settings::set('mailchimp_list_id', $mailchimpList); Settings::set('seo_title_suffix', $seoTitleSuffix); Settings::set('seo_meta_description', $seoMetaDescription); Settings::set('seo_robots_index', $seoRobotsIndex); Settings::set('seo_robots_follow', $seoRobotsFollow); Settings::set('seo_og_image', $seoOgImage); Audit::log('settings.save', [ 'updated_keys' => [ 'footer_text', 'footer_links_json', 'site_header_*', 'fontawesome_*', 'site_maintenance_*', 'smtp_*', 'mailchimp_*', 'seo_*', ], ]); return new Response('', 302, ['Location' => '/admin/settings']); } private function installSetupCore(): Response { $dbHost = trim((string)($_POST['db_host'] ?? 'localhost')); $dbName = trim((string)($_POST['db_name'] ?? '')); $dbUser = trim((string)($_POST['db_user'] ?? '')); $dbPass = (string)($_POST['db_pass'] ?? ''); $dbPort = (int)($_POST['db_port'] ?? 3306); $adminName = trim((string)($_POST['admin_name'] ?? 'Admin')); $adminEmail = strtolower(trim((string)($_POST['admin_email'] ?? ''))); $adminPass = (string)($_POST['admin_password'] ?? ''); $defaults = $this->installerDefaultValues(); $values = [ 'db_host' => $dbHost, 'db_name' => $dbName, 'db_user' => $dbUser, 'db_port' => (string)$dbPort, 'admin_name' => $adminName !== '' ? $adminName : 'Admin', 'admin_email' => $adminEmail, 'site_title' => $defaults['site_title'], 'site_tagline' => $defaults['site_tagline'], 'seo_title_suffix' => $defaults['seo_title_suffix'], 'seo_meta_description' => $defaults['seo_meta_description'], 'smtp_host' => $defaults['smtp_host'], 'smtp_port' => $defaults['smtp_port'], 'smtp_user' => $defaults['smtp_user'], 'smtp_pass' => $defaults['smtp_pass'], 'smtp_encryption' => $defaults['smtp_encryption'], 'smtp_from_email' => $defaults['smtp_from_email'], 'smtp_from_name' => $defaults['smtp_from_name'], 'smtp_test_email' => $adminEmail, ]; if ($dbName === '' || $dbUser === '' || $adminEmail === '' || $adminPass === '') { $_SESSION['installer'] = [ 'core_ready' => false, 'values' => $values, ]; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Please fill all required fields.')]); } if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) { $_SESSION['installer'] = [ 'core_ready' => false, 'values' => $values, ]; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Admin email is not valid.')]); } if (strlen($adminPass) < 8) { $_SESSION['installer'] = [ 'core_ready' => false, 'values' => $values, ]; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Admin password must be at least 8 characters.')]); } $config = " '" . addslashes($dbHost) . "',\n" . " 'database' => '" . addslashes($dbName) . "',\n" . " 'user' => '" . addslashes($dbUser) . "',\n" . " 'pass' => '" . addslashes($dbPass) . "',\n" . " 'port' => " . (int)$dbPort . ",\n" . "];\n"; $configPath = __DIR__ . '/../../storage/db.php'; if (file_put_contents($configPath, $config) === false) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to write DB config file.')]); } try { $pdo = $this->connectInstallerDb($dbHost, $dbName, $dbUser, $dbPass, $dbPort); } catch (Throwable $e) { $_SESSION['installer'] = [ 'core_ready' => false, 'values' => $values, ]; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Database connection failed. Check credentials.')]); } try { $this->createInstallerTables($pdo); } catch (Throwable $e) { $_SESSION['installer'] = [ 'core_ready' => false, 'values' => $values, ]; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to create core tables.')]); } $hash = password_hash($adminPass, PASSWORD_DEFAULT); $adminId = 0; try { $stmt = $pdo->prepare("SELECT id FROM ac_admin_users WHERE email = :email LIMIT 1"); $stmt->execute([':email' => $adminEmail]); $existing = $stmt->fetch(PDO::FETCH_ASSOC); if ($existing) { $adminId = (int)$existing['id']; $update = $pdo->prepare("UPDATE ac_admin_users SET name = :name, password_hash = :hash, role = 'admin' WHERE id = :id"); $update->execute([ ':name' => $adminName !== '' ? $adminName : 'Admin', ':hash' => $hash, ':id' => $adminId, ]); } else { $insert = $pdo->prepare(" INSERT INTO ac_admin_users (name, email, password_hash, role) VALUES (:name, :email, :hash, 'admin') "); $insert->execute([ ':name' => $adminName !== '' ? $adminName : 'Admin', ':email' => $adminEmail, ':hash' => $hash, ]); $adminId = (int)$pdo->lastInsertId(); } $stmt = $pdo->prepare("SELECT id FROM ac_admins WHERE email = :email LIMIT 1"); $stmt->execute([':email' => $adminEmail]); $legacy = $stmt->fetch(PDO::FETCH_ASSOC); if ($legacy) { $update = $pdo->prepare("UPDATE ac_admins SET name = :name, password_hash = :hash WHERE id = :id"); $update->execute([ ':name' => $adminName !== '' ? $adminName : 'Admin', ':hash' => $hash, ':id' => (int)$legacy['id'], ]); } else { $insert = $pdo->prepare("INSERT INTO ac_admins (name, email, password_hash) VALUES (:name, :email, :hash)"); $insert->execute([ ':name' => $adminName !== '' ? $adminName : 'Admin', ':email' => $adminEmail, ':hash' => $hash, ]); } } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to create admin account.')]); } try { $seed = $pdo->prepare("REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v)"); $seed->execute([':k' => 'site_title', ':v' => $defaults['site_title']]); $seed->execute([':k' => 'site_header_title', ':v' => $defaults['site_title']]); $seed->execute([':k' => 'site_header_tagline', ':v' => $defaults['site_tagline']]); $seed->execute([':k' => 'footer_text', ':v' => $defaults['site_title']]); $seed->execute([':k' => 'seo_title_suffix', ':v' => $defaults['seo_title_suffix']]); $seed->execute([':k' => 'seo_meta_description', ':v' => $defaults['seo_meta_description']]); $seed->execute([':k' => 'seo_robots_index', ':v' => '1']); $seed->execute([':k' => 'seo_robots_follow', ':v' => '1']); $count = (int)$pdo->query("SELECT COUNT(*) FROM ac_nav_links")->fetchColumn(); if ($count === 0) { $navInsert = $pdo->prepare(" INSERT INTO ac_nav_links (label, url, sort_order, is_active) VALUES (:label, :url, :sort_order, 1) "); $navInsert->execute([':label' => 'Home', ':url' => '/', ':sort_order' => 1]); $navInsert->execute([':label' => 'Artists', ':url' => '/artists', ':sort_order' => 2]); $navInsert->execute([':label' => 'Releases', ':url' => '/releases', ':sort_order' => 3]); $navInsert->execute([':label' => 'Store', ':url' => '/store', ':sort_order' => 4]); $navInsert->execute([':label' => 'Contact', ':url' => '/contact', ':sort_order' => 5]); } } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Core setup completed but settings seed failed.')]); } $_SESSION['installer'] = [ 'core_ready' => true, 'admin_id' => $adminId, 'admin_name' => $adminName !== '' ? $adminName : 'Admin', 'db' => [ 'host' => $dbHost, 'name' => $dbName, 'user' => $dbUser, 'pass' => $dbPass, 'port' => $dbPort, ], 'values' => $values, 'smtp_result' => [], 'checks' => [], ]; return new Response('', 302, ['Location' => '/admin/installer?success=' . rawurlencode('Core setup complete. Configure SMTP and run a test email.')]); } private function installTestSmtp(): Response { $installer = $_SESSION['installer'] ?? []; if (empty($installer['core_ready'])) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Complete Step 1 first.')]); } $values = $this->installerSanitizedStepTwoValues((array)$_POST, (array)($installer['values'] ?? [])); $testEmail = strtolower(trim((string)($_POST['smtp_test_email'] ?? ''))); $values['smtp_test_email'] = $testEmail; if ($testEmail === '' || !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { $installer['values'] = $values; $installer['smtp_result'] = [ 'ok' => false, 'message' => 'Enter a valid test recipient email.', 'debug' => '', ]; $_SESSION['installer'] = $installer; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('SMTP test requires a valid recipient email.')]); } $smtpSettings = [ 'smtp_host' => $values['smtp_host'], 'smtp_port' => $values['smtp_port'], 'smtp_user' => $values['smtp_user'], 'smtp_pass' => $values['smtp_pass'], 'smtp_encryption' => $values['smtp_encryption'], 'smtp_from_email' => $values['smtp_from_email'], 'smtp_from_name' => $values['smtp_from_name'], ]; $subject = 'AudioCore installer SMTP test'; $html = '

SMTP test successful

Your AudioCore V1.5 installer SMTP settings are valid.

' . '

Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC

'; $mail = Mailer::send($testEmail, $subject, $html, $smtpSettings); $checks = $this->installerHealthChecks((array)($installer['db'] ?? []), $values); $installer['values'] = $values; $installer['smtp_result'] = [ 'ok' => !empty($mail['ok']), 'message' => !empty($mail['ok']) ? 'SMTP test email sent successfully.' : (string)($mail['error'] ?? 'SMTP test failed.'), 'debug' => (string)($mail['debug'] ?? ''), 'fingerprint' => hash('sha256', json_encode($smtpSettings)), ]; $installer['checks'] = $checks; $_SESSION['installer'] = $installer; if (!empty($mail['ok'])) { return new Response('', 302, ['Location' => '/admin/installer?success=' . rawurlencode('SMTP test passed. You can finish installation.')]); } return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('SMTP test failed: ' . (string)($mail['error'] ?? 'Unknown error'))]); } private function installFinish(): Response { $installer = $_SESSION['installer'] ?? []; if (empty($installer['core_ready'])) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Complete Step 1 first.')]); } $values = $this->installerSanitizedStepTwoValues((array)$_POST, (array)($installer['values'] ?? [])); $values['smtp_test_email'] = strtolower(trim((string)($_POST['smtp_test_email'] ?? ($values['smtp_test_email'] ?? '')))); $smtpSettings = [ 'smtp_host' => $values['smtp_host'], 'smtp_port' => $values['smtp_port'], 'smtp_user' => $values['smtp_user'], 'smtp_pass' => $values['smtp_pass'], 'smtp_encryption' => $values['smtp_encryption'], 'smtp_from_email' => $values['smtp_from_email'], 'smtp_from_name' => $values['smtp_from_name'], ]; $currentFingerprint = hash('sha256', json_encode($smtpSettings)); $testedFingerprint = (string)($installer['smtp_result']['fingerprint'] ?? ''); $smtpPassed = !empty($installer['smtp_result']['ok']) && $testedFingerprint !== '' && hash_equals($testedFingerprint, $currentFingerprint); if (!$smtpPassed) { $installer['values'] = $values; $_SESSION['installer'] = $installer; return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Run SMTP test successfully before finishing. Re-test if SMTP values changed.')]); } $dbConf = (array)($installer['db'] ?? []); try { $pdo = $this->connectInstallerDb( (string)($dbConf['host'] ?? ''), (string)($dbConf['name'] ?? ''), (string)($dbConf['user'] ?? ''), (string)($dbConf['pass'] ?? ''), (int)($dbConf['port'] ?? 3306) ); } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Database connection failed while finalizing installation.')]); } try { $stmt = $pdo->prepare("REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v)"); $pairs = [ 'site_title' => $values['site_title'], 'site_header_title' => $values['site_title'], 'site_header_tagline' => $values['site_tagline'], 'seo_title_suffix' => $values['seo_title_suffix'], 'seo_meta_description' => $values['seo_meta_description'], 'seo_robots_index' => '1', 'seo_robots_follow' => '1', 'footer_text' => $values['site_title'], 'smtp_host' => $values['smtp_host'], 'smtp_port' => $values['smtp_port'], 'smtp_user' => $values['smtp_user'], 'smtp_pass' => $values['smtp_pass'], 'smtp_encryption' => $values['smtp_encryption'], 'smtp_from_email' => $values['smtp_from_email'], 'smtp_from_name' => $values['smtp_from_name'], ]; foreach ($pairs as $key => $value) { $stmt->execute([':k' => $key, ':v' => (string)$value]); } } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Failed to save site settings.')]); } Settings::reload(); $adminId = (int)($installer['admin_id'] ?? 0); $adminName = (string)($installer['admin_name'] ?? 'Admin'); if ($adminId > 0) { Auth::login($adminId, 'admin', $adminName !== '' ? $adminName : 'Admin'); } unset($_SESSION['installer']); return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Installation complete.')]); } private function installerDefaultValues(): array { return [ 'site_title' => 'AudioCore V1.5', 'site_tagline' => 'Core CMS for DJs & Producers', 'seo_title_suffix' => 'AudioCore V1.5', 'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.', 'smtp_host' => '', 'smtp_port' => '587', 'smtp_user' => '', 'smtp_pass' => '', 'smtp_encryption' => 'tls', 'smtp_from_email' => '', 'smtp_from_name' => 'AudioCore V1.5', ]; } private function installerSanitizedStepTwoValues(array $post, array $existing): array { $defaults = array_merge($this->installerDefaultValues(), $existing); return [ 'db_host' => (string)($existing['db_host'] ?? ''), 'db_name' => (string)($existing['db_name'] ?? ''), 'db_user' => (string)($existing['db_user'] ?? ''), 'db_port' => (string)($existing['db_port'] ?? '3306'), 'admin_name' => (string)($existing['admin_name'] ?? 'Admin'), 'admin_email' => (string)($existing['admin_email'] ?? ''), 'site_title' => trim((string)($post['site_title'] ?? $defaults['site_title'])), 'site_tagline' => trim((string)($post['site_tagline'] ?? $defaults['site_tagline'])), 'seo_title_suffix' => trim((string)($post['seo_title_suffix'] ?? $defaults['seo_title_suffix'])), 'seo_meta_description' => trim((string)($post['seo_meta_description'] ?? $defaults['seo_meta_description'])), 'smtp_host' => trim((string)($post['smtp_host'] ?? $defaults['smtp_host'])), 'smtp_port' => trim((string)($post['smtp_port'] ?? $defaults['smtp_port'])), 'smtp_user' => trim((string)($post['smtp_user'] ?? $defaults['smtp_user'])), 'smtp_pass' => (string)($post['smtp_pass'] ?? $defaults['smtp_pass']), 'smtp_encryption' => trim((string)($post['smtp_encryption'] ?? $defaults['smtp_encryption'])), 'smtp_from_email' => trim((string)($post['smtp_from_email'] ?? $defaults['smtp_from_email'])), 'smtp_from_name' => trim((string)($post['smtp_from_name'] ?? $defaults['smtp_from_name'])), 'smtp_test_email' => trim((string)($post['smtp_test_email'] ?? ($defaults['smtp_test_email'] ?? ''))), ]; } private function installerHealthChecks(array $dbConf, array $values): array { $checks = []; try { $pdo = $this->connectInstallerDb( (string)($dbConf['host'] ?? ''), (string)($dbConf['name'] ?? ''), (string)($dbConf['user'] ?? ''), (string)($dbConf['pass'] ?? ''), (int)($dbConf['port'] ?? 3306) ); $pdo->query("SELECT 1"); $checks[] = ['label' => 'Database connection', 'ok' => true, 'detail' => 'Connected successfully.']; $hasSettings = $pdo->query("SHOW TABLES LIKE 'ac_settings'")->fetchColumn() !== false; $checks[] = ['label' => 'Core tables', 'ok' => $hasSettings, 'detail' => $hasSettings ? 'ac_settings found.' : 'ac_settings missing.']; } catch (Throwable $e) { $checks[] = ['label' => 'Database connection', 'ok' => false, 'detail' => 'Connection/query failed.']; } $storagePath = __DIR__ . '/../../storage'; $checks[] = [ 'label' => 'Storage directory writable', 'ok' => is_dir($storagePath) && is_writable($storagePath), 'detail' => $storagePath, ]; $uploadsPath = __DIR__ . '/../../uploads'; $uploadsOk = is_dir($uploadsPath) ? is_writable($uploadsPath) : @mkdir($uploadsPath, 0755, true); $checks[] = [ 'label' => 'Uploads directory writable', 'ok' => (bool)$uploadsOk, 'detail' => $uploadsPath, ]; $checks[] = [ 'label' => 'SMTP sender configured', 'ok' => $values['smtp_from_email'] !== '' || $values['smtp_user'] !== '', 'detail' => 'Use SMTP From Email or SMTP User.', ]; return $checks; } private function connectInstallerDb(string $host, string $dbName, string $dbUser, string $dbPass, int $dbPort): PDO { $dsn = "mysql:host={$host};port={$dbPort};dbname={$dbName};charset=utf8mb4"; return new PDO($dsn, $dbUser, $dbPass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); } private function createInstallerTables(PDO $db): void { $db->exec(" CREATE TABLE IF NOT EXISTS ac_admins ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(190) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_settings ( setting_key VARCHAR(120) PRIMARY KEY, setting_value TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_pages ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, content_html MEDIUMTEXT NOT NULL, is_published TINYINT(1) NOT NULL DEFAULT 0, is_home TINYINT(1) NOT NULL DEFAULT 0, is_blog_index TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, excerpt TEXT NULL, featured_image_url VARCHAR(255) NULL, author_name VARCHAR(120) NULL, category VARCHAR(120) NULL, tags VARCHAR(255) NULL, content_html MEDIUMTEXT NOT NULL, is_published TINYINT(1) NOT NULL DEFAULT 0, published_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_admin_users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(190) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'editor', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_nav_links ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, label VARCHAR(120) NOT NULL, url VARCHAR(255) NOT NULL, sort_order INT NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_newsletter_subscribers ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(190) NOT NULL UNIQUE, name VARCHAR(120) NULL, status VARCHAR(20) NOT NULL DEFAULT 'subscribed', source VARCHAR(50) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, unsubscribed_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_newsletter_campaigns ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, subject VARCHAR(200) NOT NULL, content_html MEDIUMTEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME NULL, scheduled_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_newsletter_sends ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, campaign_id INT UNSIGNED NOT NULL, subscriber_id INT UNSIGNED NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', sent_at DATETIME NULL, error_text TEXT NULL, FOREIGN KEY (campaign_id) REFERENCES ac_newsletter_campaigns(id) ON DELETE CASCADE, FOREIGN KEY (subscriber_id) REFERENCES ac_newsletter_subscribers(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_media ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, file_name VARCHAR(255) NOT NULL, file_url VARCHAR(255) NOT NULL, file_type VARCHAR(120) NULL, file_size INT UNSIGNED NOT NULL DEFAULT 0, folder_id INT UNSIGNED NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_media_folders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_plugins ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, version VARCHAR(50) NOT NULL DEFAULT '0.0.0', is_enabled TINYINT(1) NOT NULL DEFAULT 0, installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_redirects ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, source_path VARCHAR(255) NOT NULL UNIQUE, target_url VARCHAR(1000) NOT NULL, status_code SMALLINT NOT NULL DEFAULT 301, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_audit_logs ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, actor_id INT UNSIGNED NULL, actor_name VARCHAR(120) NULL, actor_role VARCHAR(40) NULL, action VARCHAR(120) NOT NULL, context_json MEDIUMTEXT NULL, ip_address VARCHAR(45) NULL, user_agent VARCHAR(255) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_update_checks ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, channel VARCHAR(20) NOT NULL DEFAULT 'stable', manifest_url VARCHAR(500) NOT NULL DEFAULT '', current_version VARCHAR(50) NOT NULL DEFAULT '0.0.0', latest_version VARCHAR(50) NOT NULL DEFAULT '0.0.0', is_update_available TINYINT(1) NOT NULL DEFAULT 0, ok TINYINT(1) NOT NULL DEFAULT 0, error_text TEXT NULL, payload_json MEDIUMTEXT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } private function parseFooterLinks(string $json): array { $decoded = json_decode($json, true); if (!is_array($decoded)) { return []; } $out = []; foreach ($decoded as $item) { if (!is_array($item)) { continue; } $label = trim((string)($item['label'] ?? '')); $url = trim((string)($item['url'] ?? '')); if ($label === '' || $url === '') { continue; } $out[] = [ 'label' => mb_substr($label, 0, 80), 'url' => mb_substr($this->normalizeUrl($url), 0, 255), ]; if (count($out) >= 20) { break; } } return $out; } private function uploadHeaderLogo(): Response { $file = $_FILES['header_logo_file'] ?? null; if (!$file || !isset($file['tmp_name'])) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('No file selected.')]); } if ((int)$file['error'] !== UPLOAD_ERR_OK) { $map = [ UPLOAD_ERR_INI_SIZE => 'File exceeds upload limit.', UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit.', UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'Missing temp upload directory.', UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', UPLOAD_ERR_EXTENSION => 'Upload stopped by server extension.', UPLOAD_ERR_NO_FILE => 'No file uploaded.', ]; $msg = $map[(int)$file['error']] ?? 'Upload failed.'; return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode($msg)]); } $tmp = (string)$file['tmp_name']; if ($tmp === '' || !is_uploaded_file($tmp)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Upload validation failed.')]); } $info = @getimagesize($tmp); if ($info === false) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Logo must be an image file.')]); } $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); if ($ext === '') { $ext = image_type_to_extension((int)($info[2] ?? IMAGETYPE_PNG), false) ?: 'png'; } $allowed = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg']; if (!in_array($ext, $allowed, true)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Allowed types: PNG, JPG, WEBP, GIF, SVG.')]); } $uploadDir = __DIR__ . '/../../uploads/media'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Upload directory could not be created.')]); } if (!is_writable($uploadDir)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Upload directory is not writable.')]); } $base = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'logo'; $base = trim($base, '-'); $filename = ($base !== '' ? $base : 'logo') . '-' . date('YmdHis') . '.' . $ext; $dest = $uploadDir . '/' . $filename; if (!move_uploaded_file($tmp, $dest)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Upload failed while moving file.')]); } $url = '/uploads/media/' . $filename; Settings::set('site_header_logo_url', $url); Audit::log('settings.logo.upload', ['logo_url' => $url]); $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare(" INSERT INTO ac_media (file_name, file_url, file_type, file_size, folder_id) VALUES (:name, :url, :type, :size, NULL) "); $stmt->execute([ ':name' => (string)($file['name'] ?? $filename), ':url' => $url, ':type' => (string)($file['type'] ?? ''), ':size' => (int)($file['size'] ?? 0), ]); } catch (Throwable $e) { } } return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Logo uploaded and applied.')]); } private function saveRedirect(): Response { $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Database unavailable.')]); } $source = trim((string)($_POST['redirect_source_path'] ?? '')); $target = trim((string)($_POST['redirect_target_url'] ?? '')); $statusCode = (int)($_POST['redirect_status_code'] ?? 301); $isActive = isset($_POST['redirect_is_active']) ? 1 : 0; if ($source === '' || $target === '') { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Redirect source and target are required.')]); } if ($source[0] !== '/') { $source = '/' . ltrim($source, '/'); } if (!in_array($statusCode, [301, 302, 307, 308], true)) { $statusCode = 301; } try { $stmt = $db->prepare(" INSERT INTO ac_redirects (source_path, target_url, status_code, is_active) VALUES (:source_path, :target_url, :status_code, :is_active) ON DUPLICATE KEY UPDATE target_url = VALUES(target_url), status_code = VALUES(status_code), is_active = VALUES(is_active), updated_at = NOW() "); $stmt->execute([ ':source_path' => $source, ':target_url' => $target, ':status_code' => $statusCode, ':is_active' => $isActive, ]); Audit::log('settings.redirect.save', [ 'source_path' => $source, 'target_url' => $target, 'status_code' => $statusCode, 'is_active' => $isActive, ]); return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Redirect saved.')]); } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Failed to save redirect.')]); } } private function deleteRedirect(): Response { $id = (int)($_POST['redirect_id'] ?? 0); if ($id <= 0) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Invalid redirect id.')]); } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Database unavailable.')]); } try { $stmt = $db->prepare("DELETE FROM ac_redirects WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); Audit::log('settings.redirect.delete', ['id' => $id]); return new Response('', 302, ['Location' => '/admin/settings?status=ok&message=' . rawurlencode('Redirect deleted.')]); } catch (Throwable $e) { return new Response('', 302, ['Location' => '/admin/settings?status=error&message=' . rawurlencode('Failed to delete redirect.')]); } } private function ensureSettingsAuxTables(): void { $db = Database::get(); if (!($db instanceof PDO)) { return; } try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_redirects ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, source_path VARCHAR(255) NOT NULL UNIQUE, target_url VARCHAR(1000) NOT NULL, status_code SMALLINT NOT NULL DEFAULT 301, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { } } private function ensureCoreTables(): void { $db = Database::get(); if (!$db instanceof PDO) { return; } try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_settings ( setting_key VARCHAR(120) PRIMARY KEY, setting_value TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_admin_users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(190) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'editor', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_admins ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(190) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec(" CREATE TABLE IF NOT EXISTS ac_update_checks ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, channel VARCHAR(20) NOT NULL DEFAULT 'stable', manifest_url VARCHAR(500) NOT NULL DEFAULT '', current_version VARCHAR(50) NOT NULL DEFAULT '0.0.0', latest_version VARCHAR(50) NOT NULL DEFAULT '0.0.0', is_update_available TINYINT(1) NOT NULL DEFAULT 0, ok TINYINT(1) NOT NULL DEFAULT 0, error_text TEXT NULL, payload_json MEDIUMTEXT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { return; } } public function navigationForm(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); $links = []; $pages = []; $error = ''; if ($db instanceof PDO) { try { $stmt = $db->query("SELECT id, label, url, sort_order, is_active FROM ac_nav_links ORDER BY sort_order ASC, id ASC"); $links = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; $pagesStmt = $db->query("SELECT title, slug FROM ac_pages ORDER BY title ASC"); $pages = $pagesStmt ? $pagesStmt->fetchAll(PDO::FETCH_ASSOC) : []; } catch (Throwable $e) { $error = 'Navigation table not available.'; } } else { $error = 'Database unavailable.'; } $saved = isset($_GET['saved']) ? '1' : '0'; return new Response($this->view->render('navigation.php', [ 'title' => 'Navigation', 'links' => $links, 'pages' => $pages, 'error' => $error, 'saved' => $saved, ])); } public function saveNavigation(): Response { if ($guard = $this->guard(['admin', 'manager'])) { return $guard; } $db = Database::get(); if (!$db instanceof PDO) { return new Response('', 302, ['Location' => '/admin/navigation?error=1']); } $items = $_POST['items'] ?? []; $newItems = $_POST['new'] ?? []; $deleteIds = array_map('intval', $_POST['delete_ids'] ?? []); try { $db->beginTransaction(); if ($deleteIds) { $placeholders = implode(',', array_fill(0, count($deleteIds), '?')); $stmt = $db->prepare("DELETE FROM ac_nav_links WHERE id IN ({$placeholders})"); $stmt->execute($deleteIds); } $update = $db->prepare(" UPDATE ac_nav_links SET label = :label, url = :url, sort_order = :sort_order, is_active = :is_active WHERE id = :id "); foreach ($items as $id => $data) { $id = (int)$id; if ($id <= 0 || in_array($id, $deleteIds, true)) { continue; } $label = trim((string)($data['label'] ?? '')); $url = trim((string)($data['url'] ?? '')); if ($label === '' || $url === '') { continue; } $url = $this->normalizeUrl($url); $sortOrder = (int)($data['sort_order'] ?? 0); $isActive = isset($data['is_active']) ? 1 : 0; $update->execute([ ':label' => $label, ':url' => $url, ':sort_order' => $sortOrder, ':is_active' => $isActive, ':id' => $id, ]); } $insert = $db->prepare(" INSERT INTO ac_nav_links (label, url, sort_order, is_active) VALUES (:label, :url, :sort_order, :is_active) "); foreach ($newItems as $data) { $label = trim((string)($data['label'] ?? '')); $url = trim((string)($data['url'] ?? '')); if ($label === '' || $url === '') { continue; } $url = $this->normalizeUrl($url); $sortOrder = (int)($data['sort_order'] ?? 0); $isActive = isset($data['is_active']) ? 1 : 0; $insert->execute([ ':label' => $label, ':url' => $url, ':sort_order' => $sortOrder, ':is_active' => $isActive, ]); } $db->commit(); } catch (Throwable $e) { if ($db->inTransaction()) { $db->rollBack(); } return new Response('', 302, ['Location' => '/admin/navigation?error=1']); } return new Response('', 302, ['Location' => '/admin/navigation?saved=1']); } private function dbReady(): bool { return Database::get() instanceof PDO; } private function normalizeUrl(string $url): string { if (preg_match('~^(https?://|/|#|mailto:)~i', $url)) { return $url; } return '/' . ltrim($url, '/'); } private function baseUrl(): string { $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); $scheme = $https ? 'https' : 'http'; $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); if ($host === '') { return ''; } return $scheme . '://' . $host; } private function guard(array $roles): ?Response { $this->ensureCoreTables(); if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole($roles)) { return new Response('', 302, ['Location' => '/admin']); } return null; } }