Files
AudioCore/modules/admin/AdminController.php

1731 lines
80 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace Modules\Admin;
use Core\Http\Response;
use Core\Services\Audit;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Mailer;
use Core\Services\Permissions;
use Core\Services\Plugins;
use Core\Services\Settings;
use Core\Services\Shortcodes;
use Core\Services\Updater;
use Core\Views\View;
use PDO;
use Throwable;
class AdminController
{
private View $view;
public function __construct()
{
$this->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
{
2026-04-01 14:12:17 +00:00
if ($this->appInstalled()) {
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
}
$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
{
2026-04-01 14:12:17 +00:00
if ($this->appInstalled()) {
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
}
$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,
2026-04-01 14:12:17 +00:00
'footer_text' => Settings::get('footer_text', 'AudioCore V1.5.1'),
'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')),
2026-04-01 14:12:17 +00:00
'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5.1'),
'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', ''),
2026-04-01 14:12:17 +00:00
'site_maintenance_access_password_enabled' => Settings::get('site_maintenance_access_password_hash', '') !== '' ? '1' : '0',
'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', ''),
2026-04-01 14:12:17 +00:00
'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5.1')),
'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', ''),
'site_custom_css' => Settings::get('site_custom_css', ''),
'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' => '[latest-releases]',
'description' => 'Home-friendly alias of releases grid.',
'example' => '[latest-releases limit="8"]',
'source' => 'Releases plugin',
'enabled' => Plugins::isEnabled('releases'),
],
[
'tag' => '[new-artists]',
'description' => 'Outputs the latest active artists grid.',
'example' => '[new-artists limit="6"]',
'source' => 'Artists plugin',
'enabled' => Plugins::isEnabled('artists'),
],
[
'tag' => '[sale-chart]',
'description' => 'Outputs a best-sellers chart.',
'example' => '[sale-chart limit="10"]',
'source' => 'Store plugin',
'enabled' => Plugins::isEnabled('store'),
],
[
'tag' => '[top-sellers]',
'description' => 'Alias for sale chart block.',
'example' => '[top-sellers type="tracks" window="weekly" limit="10"]',
'source' => 'Store plugin',
'enabled' => Plugins::isEnabled('store'),
],
[
'tag' => '[hero]',
'description' => 'Home hero block with CTA buttons.',
'example' => '[hero title="Latest Drops" subtitle="Fresh releases weekly" cta_text="Browse Releases" cta_url="/releases"]',
'source' => 'Pages module',
'enabled' => true,
],
[
'tag' => '[home-catalog]',
'description' => 'Complete homepage catalog block (hero + releases + chart + artists + newsletter).',
'example' => '[home-catalog release_limit="8" artist_limit="6" chart_limit="10"]',
'source' => 'Pages module',
'enabled' => true,
],
[
'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('<p>No shortcode supplied.</p>', 400);
}
$allowedTags = [
'releases',
'latest-releases',
'new-artists',
'sale-chart',
'top-sellers',
'hero',
'home-catalog',
'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('<p>Shortcode preview not allowed.</p>', 403);
}
$rendered = Shortcodes::render($code, ['preview' => true]);
$html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
. '<title>Shortcode Preview</title>'
. '<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
. '<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">'
. '<style>'
. 'body{margin:0;padding:20px;background:#14151a;color:#f5f7ff;font-family:Syne,sans-serif;}'
. '.preview-shell{max-width:1080px;margin:0 auto;border:1px solid rgba(255,255,255,.12);border-radius:16px;background:rgba(20,22,28,.72);padding:18px;}'
. '.preview-head{font-family:"IBM Plex Mono",monospace;font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.55);margin-bottom:14px;}'
. '.ac-shortcode-empty{border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.02);border-radius:14px;padding:12px 14px;color:#9aa0b2;font-size:13px;}'
. '.ac-shortcode-release-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
. '.ac-shortcode-release-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:14px;overflow:hidden;display:grid;min-height:100%;}'
. '.ac-shortcode-release-cover{aspect-ratio:1/1;background:rgba(255,255,255,0.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-release-cover img{width:100%;height:100%;object-fit:cover;display:block;}'
. '.ac-shortcode-cover-fallback{color:#9aa0b2;font-family:"IBM Plex Mono",monospace;font-size:12px;letter-spacing:.2em;}'
. '.ac-shortcode-release-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-release-title{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-release-artist,.ac-shortcode-release-date{color:#9aa0b2;font-size:12px;}'
. '.ac-shortcode-artists-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
. '.ac-shortcode-artist-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,.1);background:rgba(15,18,24,.6);border-radius:14px;overflow:hidden;display:grid;}'
. '.ac-shortcode-artist-avatar{aspect-ratio:1/1;background:rgba(255,255,255,.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-artist-avatar img{width:100%;height:100%;object-fit:cover;display:block;}'
. '.ac-shortcode-artist-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-artist-name{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-artist-country{color:#9aa0b2;font-size:12px;}'
. '.ac-shortcode-sale-list{list-style:none;margin:0;padding:0;display:grid;gap:8px;}'
. '.ac-shortcode-sale-item{border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:10px;padding:10px 12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;}'
. '.ac-shortcode-sale-rank{font-family:"IBM Plex Mono",monospace;font-size:11px;color:#9aa0b2;letter-spacing:.15em;}'
. '.ac-shortcode-sale-title{font-size:14px;line-height:1.3;}'
. '.ac-shortcode-sale-meta{font-size:12px;color:#9aa0b2;white-space:nowrap;}'
. '.ac-shortcode-hero{border:1px solid rgba(255,255,255,.14);border-radius:18px;padding:18px;background:linear-gradient(135deg,rgba(255,255,255,.05),rgba(255,255,255,.01));display:grid;gap:10px;}'
. '.ac-shortcode-hero-eyebrow{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#9aa0b2;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-title{font-size:32px;line-height:1.05;font-weight:700;}'
. '.ac-shortcode-hero-subtitle{font-size:15px;color:#d1d7e7;max-width:72ch;}'
. '.ac-shortcode-hero-actions{display:flex;gap:8px;flex-wrap:wrap;}'
. '.ac-shortcode-hero-btn{display:inline-flex;align-items:center;justify-content:center;height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.05);color:#f5f7ff;text-decoration:none;font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-btn.primary{border-color:rgba(57,244,179,.6);background:rgba(57,244,179,.16);color:#9ff8d8;}'
. '.ac-shortcode-link{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.15);background:rgba(15,18,24,.6);color:#f5f7ff;text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase;}'
. '.ac-shortcode-link:hover{border-color:rgba(57,244,179,.6);color:#9ff8d8;}'
. '.ac-shortcode-newsletter-form{display:grid;gap:10px;border:1px solid rgba(255,255,255,.15);border-radius:14px;background:rgba(15,18,24,.6);padding:14px;}'
. '.ac-shortcode-newsletter-title{font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#9aa0b2;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-newsletter-row{display:grid;grid-template-columns:1fr auto;gap:8px;}'
. '.ac-shortcode-newsletter-input{height:40px;border:1px solid rgba(255,255,255,.16);border-radius:10px;background:rgba(8,10,16,.6);color:#f5f7ff;padding:0 12px;font-size:14px;}'
. '.ac-shortcode-newsletter-btn{height:40px;padding:0 14px;border:1px solid rgba(57,244,179,.6);border-radius:999px;background:rgba(57,244,179,.16);color:#9ff8d8;font-size:12px;letter-spacing:.14em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;cursor:pointer;}'
. '.ac-home-catalog{display:grid;gap:14px;}'
. '.ac-home-columns{display:grid;grid-template-columns:minmax(0,2.2fr) minmax(280px,1fr);gap:14px;align-items:start;}'
. '.ac-home-main,.ac-home-side{display:grid;gap:14px;align-content:start;}'
. '@media (max-width:1200px){.ac-home-columns{grid-template-columns:1fr;}}'
. '</style></head><body>'
. '<div class="preview-shell"><div class="preview-head">' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
. $rendered
. '</div></body></html>';
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'] ?? ''));
2026-04-01 14:12:17 +00:00
$maintenanceAccessPassword = trim((string)($_POST['site_maintenance_access_password'] ?? ''));
$maintenanceAccessPasswordClear = isset($_POST['site_maintenance_access_password_clear']);
$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'] ?? ''));
$siteCustomCss = trim((string)($_POST['site_custom_css'] ?? ''));
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);
2026-04-01 14:12:17 +00:00
if ($maintenanceAccessPasswordClear) {
Settings::set('site_maintenance_access_password_hash', '');
} elseif ($maintenanceAccessPassword !== '') {
Settings::set('site_maintenance_access_password_hash', password_hash($maintenanceAccessPassword, PASSWORD_DEFAULT));
}
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);
Settings::set('site_custom_css', $siteCustomCss);
Audit::log('settings.save', [
'updated_keys' => [
'footer_text', 'footer_links_json', 'site_header_*', 'fontawesome_*',
'site_maintenance_*', 'smtp_*', 'mailchimp_*', 'seo_*', 'site_custom_css',
],
]);
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 = "<?php\nreturn [\n"
. " 'host' => '" . addslashes($dbHost) . "',\n"
. " 'database' => '" . addslashes($dbName) . "',\n"
. " 'user' => '" . addslashes($dbUser) . "',\n"
. " 'pass' => '" . addslashes($dbPass) . "',\n"
. " 'port' => " . (int)$dbPort . ",\n"
. "];\n";
$storageDir = __DIR__ . '/../../storage';
if (!is_dir($storageDir)) {
if (!@mkdir($storageDir, 0775, true) && !is_dir($storageDir)) {
return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to create storage directory.')]);
}
}
$settingsPath = $storageDir . '/settings.php';
if (!is_file($settingsPath)) {
2026-04-01 14:12:17 +00:00
$settingsSeed = "<?php\nreturn [\n 'site_title' => 'AudioCore V1.5.1',\n];\n";
@file_put_contents($settingsPath, $settingsSeed);
}
$configPath = $storageDir . '/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';
2026-04-01 14:12:17 +00:00
$html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5.1 installer SMTP settings are valid.</p>'
. '<p><strong>Generated:</strong> ' . gmdate('Y-m-d H:i:s') . ' UTC</p>';
$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 [
2026-04-01 14:12:17 +00:00
'site_title' => 'AudioCore V1.5.1',
'site_tagline' => 'Core CMS for DJs & Producers',
2026-04-01 14:12:17 +00:00
'seo_title_suffix' => 'AudioCore V1.5.1',
'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' => '',
2026-04-01 14:12:17 +00:00
'smtp_from_name' => 'AudioCore V1.5.1',
];
}
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;
}
2026-04-01 14:12:17 +00:00
private function appInstalled(): bool
{
$db = Database::get();
if (!$db instanceof PDO) {
return false;
}
$this->ensureCoreTables();
try {
$adminUsers = (int)$db->query("SELECT COUNT(*) FROM ac_admin_users")->fetchColumn();
$legacyAdmins = (int)$db->query("SELECT COUNT(*) FROM ac_admins")->fetchColumn();
return ($adminUsers + $legacyAdmins) > 0;
} catch (Throwable $e) {
return false;
}
}
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;
}
}