commit b2afadd53939d6a363e26051a2727a487c389d47 Author: AudioCore Bot Date: Wed Mar 4 20:46:11 2026 +0000 Initial dev export (exclude uploads/runtime) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04d76c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Runtime/storage +storage/*.log +storage/*.cache +storage/*.sqlite +storage/mail_debug/ +storage/sessions/ +storage/cache/ + +# Uploads / media / binaries +uploads/ +public/uploads/ +*.mp3 +*.wav +*.flac +*.zip +*.tar.gz +*.mov +*.mp4 +*.psd +*.ai + +# OS/editor noise +.DS_Store +Thumbs.db diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..9294863 --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ +Options -MultiViews +RewriteEngine On +RewriteBase / +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . index.php [L] diff --git a/.user.ini b/.user.ini new file mode 100644 index 0000000..a06c815 --- /dev/null +++ b/.user.ini @@ -0,0 +1,5 @@ +upload_max_filesize=1024M +post_max_size=1024M +memory_limit=1024M +max_execution_time = 300 +max_input_time = 300 \ No newline at end of file diff --git a/admin/.htaccess b/admin/.htaccess new file mode 100644 index 0000000..372003e --- /dev/null +++ b/admin/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ ../index.php [L] diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..6b2e29b --- /dev/null +++ b/admin/index.php @@ -0,0 +1,2 @@ +body = $body; + $this->status = $status; + $this->headers = $headers; + } + + public function send(): void + { + http_response_code($this->status); + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + echo $this->body; + } +} diff --git a/core/http/Router.php b/core/http/Router.php new file mode 100644 index 0000000..01b4383 --- /dev/null +++ b/core/http/Router.php @@ -0,0 +1,185 @@ +routes['GET'][$path] = $handler; + } + + public function post(string $path, callable $handler): void + { + $this->routes['POST'][$path] = $handler; + } + + public function registerModules(string $modulesPath): void + { + foreach (glob($modulesPath . '/*/module.php') as $moduleFile) { + $module = require $moduleFile; + if (is_callable($module)) { + $module($this); + } + } + } + + public function dispatch(string $uri, string $method): Response + { + $path = parse_url($uri, PHP_URL_PATH) ?: '/'; + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + $method = strtoupper($method); + + + if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) { + return new Response('Invalid request token. Please refresh and try again.', 419); + } + + if ($method === 'GET') { + $redirect = $this->matchRedirect($path); + if ($redirect !== null) { + return $redirect; + } + } + + if (str_starts_with($path, '/admin')) { + $permission = Permissions::routePermission($path); + if ($permission !== null && Auth::check() && !Auth::can($permission)) { + return new Response('', 302, ['Location' => '/admin?denied=1']); + } + } + + if (isset($this->routes[$method][$path])) { + $handler = $this->routes[$method][$path]; + return $handler(); + } + + if ($method === 'GET') { + if (str_starts_with($path, '/news/')) { + $postSlug = trim(substr($path, strlen('/news/'))); + if ($postSlug !== '' && strpos($postSlug, '/') === false) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare(" + SELECT title, content_html, published_at, featured_image_url, author_name, category, tags + FROM ac_posts + WHERE slug = :slug AND is_published = 1 + LIMIT 1 + "); + $stmt->execute([':slug' => $postSlug]); + $post = $stmt->fetch(PDO::FETCH_ASSOC); + if ($post) { + $view = new View(__DIR__ . '/../../modules/blog/views'); + return new Response($view->render('site/show.php', [ + 'title' => (string)$post['title'], + 'content_html' => (string)$post['content_html'], + 'published_at' => (string)($post['published_at'] ?? ''), + 'featured_image_url' => (string)($post['featured_image_url'] ?? ''), + 'author_name' => (string)($post['author_name'] ?? ''), + 'category' => (string)($post['category'] ?? ''), + 'tags' => (string)($post['tags'] ?? ''), + ])); + } + } catch (Throwable $e) { + } + } + } + } + + $slug = ltrim($path, '/'); + if ($slug !== '' && strpos($slug, '/') === false) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1"); + $stmt->execute([':slug' => $slug]); + $page = $stmt->fetch(PDO::FETCH_ASSOC); + if ($page) { + $view = new View(__DIR__ . '/../../modules/pages/views'); + return new Response($view->render('site/show.php', [ + 'title' => (string)$page['title'], + 'content_html' => Shortcodes::render((string)$page['content_html'], [ + 'page_slug' => (string)$slug, + ]), + ])); + } + } catch (Throwable $e) { + } + } + } + } + + $view = new View(); + return new Response($view->render('site/404.php', [ + 'title' => 'Not Found', + 'message' => 'Page not found.', + ]), 404); + } + + + + private function requiresCsrf(string $path): bool + { + // All browser-initiated POST routes require CSRF protection. + return true; + } + + private function matchRedirect(string $path): ?Response + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return null; + } + + 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; + "); + $stmt = $db->prepare(" + SELECT target_url, status_code + FROM ac_redirects + WHERE source_path = :path AND is_active = 1 + LIMIT 1 + "); + $stmt->execute([':path' => $path]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $code = (int)($row['status_code'] ?? 301); + if (!in_array($code, [301, 302, 307, 308], true)) { + $code = 301; + } + $target = trim((string)($row['target_url'] ?? '')); + if ($target === '' || $target === $path) { + return null; + } + return new Response('', $code, ['Location' => $target]); + } catch (Throwable $e) { + return null; + } + } +} diff --git a/core/services/Audit.php b/core/services/Audit.php new file mode 100644 index 0000000..1c4172f --- /dev/null +++ b/core/services/Audit.php @@ -0,0 +1,105 @@ +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; + "); + } catch (Throwable $e) { + return; + } + } + + public static function log(string $action, array $context = []): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + self::ensureTable(); + try { + $stmt = $db->prepare(" + INSERT INTO ac_audit_logs + (actor_id, actor_name, actor_role, action, context_json, ip_address, user_agent) + VALUES + (:actor_id, :actor_name, :actor_role, :action, :context_json, :ip_address, :user_agent) + "); + $stmt->execute([ + ':actor_id' => Auth::id() > 0 ? Auth::id() : null, + ':actor_name' => Auth::name() !== '' ? Auth::name() : null, + ':actor_role' => Auth::role() !== '' ? Auth::role() : null, + ':action' => $action, + ':context_json' => $context ? json_encode($context, JSON_UNESCAPED_SLASHES) : null, + ':ip_address' => self::ip(), + ':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + return; + } + } + + public static function latest(int $limit = 100): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + self::ensureTable(); + $limit = max(1, min(500, $limit)); + try { + $stmt = $db->prepare(" + SELECT id, actor_name, actor_role, action, context_json, ip_address, created_at + FROM ac_audit_logs + ORDER BY id DESC + LIMIT :limit + "); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + return []; + } + } + + private static function ip(): ?string + { + $candidates = [ + (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), + (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), + (string)($_SERVER['REMOTE_ADDR'] ?? ''), + ]; + foreach ($candidates as $candidate) { + if ($candidate === '') { + continue; + } + $first = trim(explode(',', $candidate)[0] ?? ''); + if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) { + return $first; + } + } + return null; + } +} diff --git a/core/services/Auth.php b/core/services/Auth.php new file mode 100644 index 0000000..9b50a0a --- /dev/null +++ b/core/services/Auth.php @@ -0,0 +1,80 @@ + true, + 'cookie_secure' => $secure, + 'cookie_samesite' => 'Lax', + 'use_strict_mode' => 1, + ]); + } + } + + public static function check(): bool + { + self::init(); + return isset($_SESSION[self::SESSION_KEY]); + } + + public static function id(): int + { + self::init(); + return (int)($_SESSION[self::SESSION_KEY] ?? 0); + } + + public static function login(int $adminId, string $role = 'admin', string $name = ''): void + { + self::init(); + session_regenerate_id(true); + $_SESSION[self::SESSION_KEY] = $adminId; + $_SESSION[self::SESSION_ROLE_KEY] = $role; + if ($name !== '') { + $_SESSION[self::SESSION_NAME_KEY] = $name; + } + } + + public static function logout(): void + { + self::init(); + unset($_SESSION[self::SESSION_KEY]); + unset($_SESSION[self::SESSION_ROLE_KEY]); + unset($_SESSION[self::SESSION_NAME_KEY]); + } + + public static function role(): string + { + self::init(); + return (string)($_SESSION[self::SESSION_ROLE_KEY] ?? 'admin'); + } + + public static function name(): string + { + self::init(); + return (string)($_SESSION[self::SESSION_NAME_KEY] ?? 'Admin'); + } + + public static function hasRole(array $roles): bool + { + return in_array(self::role(), $roles, true); + } + + public static function can(string $permission): bool + { + if (!self::check()) { + return false; + } + return Permissions::can(self::role(), $permission); + } +} diff --git a/core/services/Csrf.php b/core/services/Csrf.php new file mode 100644 index 0000000..ebbfbf8 --- /dev/null +++ b/core/services/Csrf.php @@ -0,0 +1,50 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } catch (PDOException $e) { + return null; + } + + return self::$pdo; + } +} diff --git a/core/services/Mailer.php b/core/services/Mailer.php new file mode 100644 index 0000000..33120c3 --- /dev/null +++ b/core/services/Mailer.php @@ -0,0 +1,226 @@ +" : $fromEmail; + + if ($host === '') { + $headers = [ + 'MIME-Version: 1.0', + 'Content-type: text/html; charset=utf-8', + "From: {$fromHeader}", + ]; + $ok = mail($to, $subject, $html, implode("\r\n", $headers)); + return ['ok' => $ok, 'error' => $ok ? '' : 'mail() failed', 'debug' => 'transport=mail()']; + } + + $remote = $encryption === 'ssl' ? "ssl://{$host}:{$port}" : "{$host}:{$port}"; + $socket = stream_socket_client($remote, $errno, $errstr, 10); + if (!$socket) { + return ['ok' => false, 'error' => "SMTP connect failed: {$errstr}", 'debug' => "connect={$remote} errno={$errno} err={$errstr}"]; + } + + $debug = []; + + $read = function () use ($socket): string { + $data = ''; + while (!feof($socket)) { + $line = fgets($socket, 515); + if ($line === false) { + break; + } + $data .= $line; + if (isset($line[3]) && $line[3] === ' ') { + break; + } + } + return $data; + }; + + $send = function (string $command) use ($socket, $read): string { + fwrite($socket, $command . "\r\n"); + return $read(); + }; + + $resp = $read(); + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP greeting failed', 'debug' => implode("\n", $debug)]; + } + + $resp = $send("EHLO localhost"); + $debug[] = 'C: EHLO localhost'; + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP EHLO failed', 'debug' => implode("\n", $debug)]; + } + $authCaps = self::parseAuthCapabilities($resp); + + if ($encryption === 'tls') { + $resp = $send("STARTTLS"); + $debug[] = 'C: STARTTLS'; + $debug[] = 'S: ' . trim($resp); + if (substr(trim($resp), 0, 3) !== '220') { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP STARTTLS failed', 'debug' => implode("\n", $debug)]; + } + if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP TLS negotiation failed', 'debug' => implode("\n", $debug)]; + } + $resp = $send("EHLO localhost"); + $debug[] = 'C: EHLO localhost'; + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP EHLO after TLS failed', 'debug' => implode("\n", $debug)]; + } + $authCaps = self::parseAuthCapabilities($resp); + } + + if ($user !== '' && $pass !== '') { + $authOk = false; + $authErrors = []; + + // Prefer advertised AUTH mechanisms when available. + if (in_array('PLAIN', $authCaps, true)) { + $authResp = $send("AUTH PLAIN " . base64_encode("\0{$user}\0{$pass}")); + $debug[] = 'C: AUTH PLAIN [credentials]'; + $debug[] = 'S: ' . trim($authResp); + if (substr(trim($authResp), 0, 3) === '235') { + $authOk = true; + } else { + $authErrors[] = 'PLAIN rejected'; + } + } + + if (!$authOk && (in_array('LOGIN', $authCaps, true) || !$authCaps)) { + $resp = $send("AUTH LOGIN"); + $debug[] = 'C: AUTH LOGIN'; + $debug[] = 'S: ' . trim($resp); + if (substr(trim($resp), 0, 3) === '334') { + $resp = $send(base64_encode($user)); + $debug[] = 'C: [username]'; + $debug[] = 'S: ' . trim($resp); + if (substr(trim($resp), 0, 3) === '334') { + $resp = $send(base64_encode($pass)); + $debug[] = 'C: [password]'; + $debug[] = 'S: ' . trim($resp); + if (substr(trim($resp), 0, 3) === '235') { + $authOk = true; + } else { + $authErrors[] = 'LOGIN password rejected'; + } + } else { + $authErrors[] = 'LOGIN username rejected'; + } + } else { + $authErrors[] = 'LOGIN command rejected'; + } + } + + if (!$authOk) { + fclose($socket); + $err = $authErrors ? implode(', ', $authErrors) : 'No supported AUTH method'; + return ['ok' => false, 'error' => 'SMTP authentication failed: ' . $err, 'debug' => implode("\n", $debug)]; + } + } + + $resp = $send("MAIL FROM:<{$fromEmail}>"); + $debug[] = "C: MAIL FROM:<{$fromEmail}>"; + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP MAIL FROM failed', 'debug' => implode("\n", $debug)]; + } + $resp = $send("RCPT TO:<{$to}>"); + $debug[] = "C: RCPT TO:<{$to}>"; + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP RCPT TO failed', 'debug' => implode("\n", $debug)]; + } + $resp = $send("DATA"); + $debug[] = 'C: DATA'; + $debug[] = 'S: ' . trim($resp); + if (substr(trim($resp), 0, 3) !== '354') { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP DATA failed', 'debug' => implode("\n", $debug)]; + } + + $headers = [ + "From: {$fromHeader}", + "To: {$to}", + "Subject: {$subject}", + "MIME-Version: 1.0", + "Content-Type: text/html; charset=utf-8", + ]; + $message = implode("\r\n", $headers) . "\r\n\r\n" . $html . "\r\n."; + fwrite($socket, $message . "\r\n"); + $resp = $read(); + $debug[] = 'C: [message body]'; + $debug[] = 'S: ' . trim($resp); + if (!self::isOkResponse($resp)) { + fclose($socket); + return ['ok' => false, 'error' => 'SMTP message rejected', 'debug' => implode("\n", $debug)]; + } + $resp = $send("QUIT"); + $debug[] = 'C: QUIT'; + $debug[] = 'S: ' . trim($resp); + fclose($socket); + + return ['ok' => true, 'error' => '', 'debug' => implode("\n", $debug)]; + } + + private static function isOkResponse(string $response): bool + { + $code = substr(trim($response), 0, 3); + if ($code === '') { + return false; + } + return $code[0] === '2' || $code[0] === '3'; + } + + /** + * @return string[] + */ + private static function parseAuthCapabilities(string $ehloResponse): array + { + $caps = []; + foreach (preg_split('/\r?\n/', $ehloResponse) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $line = preg_replace('/^\d{3}[ -]/', '', $line) ?? $line; + if (stripos($line, 'AUTH ') === 0) { + $parts = preg_split('/\s+/', substr($line, 5)) ?: []; + foreach ($parts as $p) { + $p = strtoupper(trim($p)); + if ($p !== '') { + $caps[] = $p; + } + } + } + } + return array_values(array_unique($caps)); + } +} diff --git a/core/services/Nav.php b/core/services/Nav.php new file mode 100644 index 0000000..5f17865 --- /dev/null +++ b/core/services/Nav.php @@ -0,0 +1,25 @@ +query("SELECT id, label, url, sort_order, is_active FROM ac_nav_links ORDER BY sort_order ASC, id ASC"); + $rows = $stmt->fetchAll(); + } catch (Throwable $e) { + return []; + } + return $rows ?: []; + } +} diff --git a/core/services/Permissions.php b/core/services/Permissions.php new file mode 100644 index 0000000..3432cf0 --- /dev/null +++ b/core/services/Permissions.php @@ -0,0 +1,145 @@ + 'core.dashboard', 'label' => 'Dashboard', 'group' => 'Core'], + ['key' => 'core.settings', 'label' => 'Settings', 'group' => 'Core'], + ['key' => 'core.navigation', 'label' => 'Navigation', 'group' => 'Core'], + ['key' => 'core.accounts', 'label' => 'Accounts', 'group' => 'Core'], + ['key' => 'core.plugins', 'label' => 'Plugins', 'group' => 'Core'], + ['key' => 'module.pages', 'label' => 'Pages', 'group' => 'Modules'], + ['key' => 'module.shortcodes', 'label' => 'Shortcodes', 'group' => 'Modules'], + ['key' => 'module.blog', 'label' => 'Blog', 'group' => 'Modules'], + ['key' => 'module.media', 'label' => 'Media', 'group' => 'Modules'], + ['key' => 'module.newsletter', 'label' => 'Newsletter', 'group' => 'Modules'], + ]; + + foreach (Plugins::all() as $plugin) { + $slug = trim((string)($plugin['slug'] ?? '')); + if ($slug === '') { + continue; + } + $defs[] = [ + 'key' => 'plugin.' . $slug, + 'label' => (string)($plugin['name'] ?? ucfirst($slug)), + 'group' => 'Plugins', + ]; + } + + return $defs; + } + + public static function matrix(): array + { + $default = self::defaultMatrix(); + $raw = Settings::get('role_permissions_json', ''); + if ($raw === '') { + return $default; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $default; + } + + foreach ($default as $perm => $roles) { + $row = $decoded[$perm] ?? null; + if (!is_array($row)) { + continue; + } + foreach (['admin', 'manager', 'editor'] as $role) { + if (array_key_exists($role, $row)) { + $default[$perm][$role] = self::toBool($row[$role]); + } + } + } + return $default; + } + + public static function saveMatrix(array $posted): void + { + $current = self::matrix(); + foreach ($current as $permission => $roles) { + foreach ($roles as $role => $allowed) { + $current[$permission][$role] = isset($posted[$permission][$role]); + } + } + Settings::set('role_permissions_json', json_encode($current, JSON_UNESCAPED_SLASHES)); + } + + public static function can(string $role, string $permission): bool + { + $matrix = self::matrix(); + if (!isset($matrix[$permission])) { + return true; + } + return (bool)($matrix[$permission][$role] ?? false); + } + + public static function routePermission(string $path): ?string + { + if ($path === '/admin') { + return 'core.dashboard'; + } + if (!str_starts_with($path, '/admin/')) { + return null; + } + $slug = trim((string)explode('/', trim(substr($path, strlen('/admin/')), '/'))[0]); + if ($slug === '' || in_array($slug, ['login', 'logout', 'install', 'installer'], true)) { + return null; + } + + $coreMap = [ + 'settings' => 'core.settings', + 'navigation' => 'core.navigation', + 'accounts' => 'core.accounts', + 'plugins' => 'core.plugins', + 'pages' => 'module.pages', + 'shortcodes' => 'module.shortcodes', + 'blog' => 'module.blog', + 'media' => 'module.media', + 'newsletter' => 'module.newsletter', + ]; + if (isset($coreMap[$slug])) { + return $coreMap[$slug]; + } + + return 'plugin.' . $slug; + } + + private static function defaultMatrix(): array + { + $matrix = []; + foreach (self::definitions() as $def) { + $key = (string)$def['key']; + $matrix[$key] = [ + 'admin' => true, + 'manager' => true, + 'editor' => false, + ]; + } + foreach (['module.pages', 'module.shortcodes', 'module.blog'] as $editorAllowed) { + if (isset($matrix[$editorAllowed])) { + $matrix[$editorAllowed]['editor'] = true; + } + } + return $matrix; + } + + private static function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return ((int)$value) === 1; + } + $v = strtolower(trim((string)$value)); + return in_array($v, ['1', 'true', 'yes', 'on'], true); + } +} diff --git a/core/services/Plugins.php b/core/services/Plugins.php new file mode 100644 index 0000000..8762d12 --- /dev/null +++ b/core/services/Plugins.php @@ -0,0 +1,258 @@ + (string)($nav['label'] ?? ''), + 'url' => (string)($nav['url'] ?? ''), + 'roles' => array_values(array_filter((array)($nav['roles'] ?? []))), + 'icon' => (string)($nav['icon'] ?? ''), + 'slug' => (string)($plugin['slug'] ?? ''), + ]; + } + return array_values(array_filter($items, static function (array $item): bool { + if ($item['label'] === '' || $item['url'] === '') { + return false; + } + $slug = trim((string)($item['slug'] ?? '')); + if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) { + return false; + } + return true; + })); + } + + public static function register(Router $router): void + { + foreach (self::enabled() as $plugin) { + $entry = (string)($plugin['entry'] ?? 'plugin.php'); + $entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry; + if (!is_file($entryPath)) { + continue; + } + $handler = require $entryPath; + if (is_callable($handler)) { + $handler($router); + } + } + } + + public static function toggle(string $slug, bool $enabled): void + { + $db = Database::get(); + if (!$db instanceof PDO) { + return; + } + try { + $stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug"); + $stmt->execute([ + ':enabled' => $enabled ? 1 : 0, + ':slug' => $slug, + ]); + } catch (Throwable $e) { + } + self::sync(); + if (!$enabled) { + $plugin = null; + foreach (self::$plugins as $item) { + if (($item['slug'] ?? '') === $slug) { + $plugin = $item; + break; + } + } + if ($plugin && !empty($plugin['pages'])) { + self::removeNavLinks($db, (array)$plugin['pages']); + } + } + } + + public static function sync(): void + { + $filesystem = self::scanFilesystem(); + $db = Database::get(); + $dbRows = []; + + if ($db instanceof PDO) { + try { + $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; + "); + $stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins"); + $dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $dbRows = []; + } + } + + $dbMap = []; + foreach ($dbRows as $row) { + $dbMap[(string)$row['slug']] = (int)$row['is_enabled']; + } + + foreach ($filesystem as $slug => $plugin) { + $plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false)); + $filesystem[$slug] = $plugin; + if ($db instanceof PDO && !isset($dbMap[$slug])) { + try { + $stmt = $db->prepare(" + INSERT INTO ac_plugins (slug, name, version, is_enabled) + VALUES (:slug, :name, :version, :enabled) + "); + $stmt->execute([ + ':slug' => $slug, + ':name' => (string)($plugin['name'] ?? $slug), + ':version' => (string)($plugin['version'] ?? '0.0.0'), + ':enabled' => $plugin['is_enabled'] ? 1 : 0, + ]); + } catch (Throwable $e) { + } + } + if ($db instanceof PDO && $plugin['is_enabled']) { + $thisPages = $plugin['pages'] ?? []; + if (is_array($thisPages) && $thisPages) { + self::ensurePages($db, $thisPages); + } + } + } + + self::$plugins = array_values($filesystem); + } + + private static function scanFilesystem(): array + { + if (self::$path === '' || !is_dir(self::$path)) { + return []; + } + + $plugins = []; + foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) { + $dir = dirname($manifestPath); + $slug = basename($dir); + $raw = file_get_contents($manifestPath); + $decoded = json_decode($raw ?: '', true); + if (!is_array($decoded)) { + $decoded = []; + } + + $plugins[$slug] = [ + 'slug' => $slug, + 'name' => (string)($decoded['name'] ?? $slug), + 'version' => (string)($decoded['version'] ?? '0.0.0'), + 'description' => (string)($decoded['description'] ?? ''), + 'author' => (string)($decoded['author'] ?? ''), + 'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null, + 'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [], + 'entry' => (string)($decoded['entry'] ?? 'plugin.php'), + 'default_enabled' => (bool)($decoded['default_enabled'] ?? false), + 'path' => $dir, + ]; + } + + return $plugins; + } + + private static function ensurePages(PDO $db, array $pages): void + { + foreach ($pages as $page) { + if (!is_array($page)) { + continue; + } + $slug = trim((string)($page['slug'] ?? '')); + $title = trim((string)($page['title'] ?? '')); + $content = (string)($page['content_html'] ?? ''); + if ($slug === '' || $title === '') { + continue; + } + try { + $stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1"); + $stmt->execute([':slug' => $slug]); + if ($stmt->fetch()) { + continue; + } + $insert = $db->prepare(" + INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index) + VALUES (:title, :slug, :content_html, 1, 0, 0) + "); + $insert->execute([ + ':title' => $title, + ':slug' => $slug, + ':content_html' => $content, + ]); + } catch (Throwable $e) { + } + } + } + + private static function removeNavLinks(PDO $db, array $pages): void + { + foreach ($pages as $page) { + if (!is_array($page)) { + continue; + } + $slug = trim((string)($page['slug'] ?? '')); + if ($slug === '') { + continue; + } + $url = '/' . ltrim($slug, '/'); + try { + $stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url"); + $stmt->execute([':url' => $url]); + } catch (Throwable $e) { + } + } + } +} diff --git a/core/services/Settings.php b/core/services/Settings.php new file mode 100644 index 0000000..bef3791 --- /dev/null +++ b/core/services/Settings.php @@ -0,0 +1,67 @@ +query("SELECT setting_key, setting_value FROM ac_settings")->fetchAll(); + foreach ($rows as $row) { + $k = (string)($row['setting_key'] ?? ''); + $v = (string)($row['setting_value'] ?? ''); + if ($k !== '') { + self::$data[$k] = $v; + } + } + } catch (Throwable $e) { + return; + } + } + + public static function set(string $key, string $value): void + { + self::$data[$key] = $value; + $db = Database::get(); + if (!$db) { + return; + } + try { + $stmt = $db->prepare("REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v)"); + $stmt->execute([':k' => $key, ':v' => $value]); + } catch (Throwable $e) { + return; + } + } +} diff --git a/core/services/Shortcodes.php b/core/services/Shortcodes.php new file mode 100644 index 0000000..86083d4 --- /dev/null +++ b/core/services/Shortcodes.php @@ -0,0 +1,54 @@ + */ + private static array $handlers = []; + + public static function register(string $name, callable $handler): void + { + $key = strtolower(trim($name)); + if ($key === '') { + return; + } + self::$handlers[$key] = $handler; + } + + public static function render(string $content, array $context = []): string + { + if ($content === '' || !self::$handlers) { + return $content; + } + + return preg_replace_callback('/\[([a-zA-Z0-9_-]+)([^\]]*)\]/', function (array $m) use ($context): string { + $tag = strtolower((string)($m[1] ?? '')); + if (!isset(self::$handlers[$tag])) { + return (string)$m[0]; + } + $attrs = self::parseAttrs((string)($m[2] ?? '')); + try { + $out = (self::$handlers[$tag])($attrs, $context); + return is_string($out) ? $out : ''; + } catch (\Throwable $e) { + return ''; + } + }, $content) ?? $content; + } + + private static function parseAttrs(string $raw): array + { + $attrs = []; + if ($raw === '') { + return $attrs; + } + if (preg_match_all('/([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/', $raw, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $attrs[strtolower((string)$match[1])] = (string)$match[2]; + } + } + return $attrs; + } +} diff --git a/core/services/Updater.php b/core/services/Updater.php new file mode 100644 index 0000000..34f3bae --- /dev/null +++ b/core/services/Updater.php @@ -0,0 +1,231 @@ + time() + ) { + return $cache; + } + + $status = [ + 'ok' => false, + 'configured' => true, + 'current_version' => $current, + 'latest_version' => $current, + 'update_available' => false, + 'channel' => $channel, + 'manifest_url' => $manifestUrl, + 'error' => '', + 'checked_at' => gmdate('c'), + 'download_url' => '', + 'changelog_url' => '', + 'notes' => '', + 'fetched_at' => time(), + ]; + + try { + $raw = self::fetchManifest($manifestUrl); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new \RuntimeException('Manifest JSON is invalid.'); + } + + $release = self::pickReleaseForChannel($decoded, $channel); + $latest = trim((string)($release['version'] ?? '')); + if ($latest === '') { + throw new \RuntimeException('Manifest does not include a version for selected channel.'); + } + + $status['ok'] = true; + $status['latest_version'] = $latest; + $status['download_url'] = (string)($release['download_url'] ?? ''); + $status['changelog_url'] = (string)($release['changelog_url'] ?? ''); + $status['notes'] = (string)($release['notes'] ?? ''); + $status['update_available'] = version_compare($latest, $current, '>'); + } catch (Throwable $e) { + $status['error'] = $e->getMessage(); + } + + self::writeCache($status); + return $status; + } + + private static function pickReleaseForChannel(array $manifest, string $channel): array + { + if (isset($manifest['channels']) && is_array($manifest['channels'])) { + $channels = $manifest['channels']; + if (isset($channels[$channel]) && is_array($channels[$channel])) { + return $channels[$channel]; + } + if (isset($channels['stable']) && is_array($channels['stable'])) { + return $channels['stable']; + } + } + return $manifest; + } + + private static function cachePath(): string + { + return __DIR__ . '/../../storage/update_cache.json'; + } + + private static function readCache(): ?array + { + try { + $db = Database::get(); + if ($db) { + $stmt = $db->query(" + SELECT checked_at, channel, manifest_url, current_version, latest_version, + is_update_available, ok, error_text, payload_json + FROM ac_update_checks + ORDER BY id DESC + LIMIT 1 + "); + $row = $stmt ? $stmt->fetch() : null; + if (is_array($row)) { + $payload = json_decode((string)($row['payload_json'] ?? ''), true); + if (is_array($payload)) { + $payload['channel'] = (string)($row['channel'] ?? ($payload['channel'] ?? 'stable')); + $payload['manifest_url'] = (string)($row['manifest_url'] ?? ($payload['manifest_url'] ?? '')); + $payload['current_version'] = (string)($row['current_version'] ?? ($payload['current_version'] ?? '0.0.0')); + $payload['latest_version'] = (string)($row['latest_version'] ?? ($payload['latest_version'] ?? '0.0.0')); + $payload['update_available'] = ((int)($row['is_update_available'] ?? 0) === 1); + $payload['ok'] = ((int)($row['ok'] ?? 0) === 1); + $payload['error'] = (string)($row['error_text'] ?? ($payload['error'] ?? '')); + $checkedAt = (string)($row['checked_at'] ?? ''); + if ($checkedAt !== '') { + $payload['checked_at'] = $checkedAt; + $payload['fetched_at'] = strtotime($checkedAt) ?: ($payload['fetched_at'] ?? 0); + } + return $payload; + } + } + } + } catch (Throwable $e) { + } + + $path = self::cachePath(); + if (!is_file($path)) { + return null; + } + $raw = @file_get_contents($path); + if ($raw === false) { + return null; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + private static function writeCache(array $data): void + { + try { + $db = Database::get(); + if ($db) { + $stmt = $db->prepare(" + INSERT INTO ac_update_checks + (checked_at, channel, manifest_url, current_version, latest_version, is_update_available, ok, error_text, payload_json) + VALUES (NOW(), :channel, :manifest_url, :current_version, :latest_version, :is_update_available, :ok, :error_text, :payload_json) + "); + $stmt->execute([ + ':channel' => (string)($data['channel'] ?? 'stable'), + ':manifest_url' => (string)($data['manifest_url'] ?? ''), + ':current_version' => (string)($data['current_version'] ?? '0.0.0'), + ':latest_version' => (string)($data['latest_version'] ?? '0.0.0'), + ':is_update_available' => !empty($data['update_available']) ? 1 : 0, + ':ok' => !empty($data['ok']) ? 1 : 0, + ':error_text' => (string)($data['error'] ?? ''), + ':payload_json' => json_encode($data, JSON_UNESCAPED_SLASHES), + ]); + } + } catch (Throwable $e) { + } + + $path = self::cachePath(); + @file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + private static function fetchManifest(string $manifestUrl): string + { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 10, + 'header' => "User-Agent: AudioCore-Updater/1.0\r\nAccept: application/json\r\n", + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $raw = @file_get_contents($manifestUrl, false, $ctx); + if (is_string($raw) && $raw !== '') { + return $raw; + } + + if (!function_exists('curl_init')) { + throw new \RuntimeException('Unable to fetch manifest (file_get_contents failed and cURL is unavailable).'); + } + + $ch = curl_init($manifestUrl); + if ($ch === false) { + throw new \RuntimeException('Unable to fetch manifest (failed to initialize cURL).'); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'User-Agent: AudioCore-Updater/1.0', + 'Accept: application/json', + ]); + + $body = curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if (!is_string($body) || $body === '') { + $detail = $err !== '' ? $err : ('HTTP ' . $httpCode); + throw new \RuntimeException('Unable to fetch manifest via cURL: ' . $detail); + } + if ($httpCode >= 400) { + throw new \RuntimeException('Manifest request failed with HTTP ' . $httpCode . '.'); + } + + return $body; + } +} diff --git a/core/version.php b/core/version.php new file mode 100644 index 0000000..fd0daf7 --- /dev/null +++ b/core/version.php @@ -0,0 +1,7 @@ + '1.5.0', +]; + diff --git a/core/views/View.php b/core/views/View.php new file mode 100644 index 0000000..67cffe3 --- /dev/null +++ b/core/views/View.php @@ -0,0 +1,31 @@ +basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views'; + } + + public function render(string $template, array $vars = []): string + { + $path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template; + if (!is_file($path)) { + error_log('AC View missing: ' . $path); + return ''; + } + + if ($vars) { + extract($vars, EXTR_SKIP); + } + + ob_start(); + require $path; + return ob_get_clean() ?: ''; + } +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..d4a3596 --- /dev/null +++ b/index.php @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/modules/admin/AdminController.php b/modules/admin/AdminController.php new file mode 100644 index 0000000..24a9977 --- /dev/null +++ b/modules/admin/AdminController.php @@ -0,0 +1,1626 @@ +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; + } +} diff --git a/modules/admin/module.php b/modules/admin/module.php new file mode 100644 index 0000000..0f0b356 --- /dev/null +++ b/modules/admin/module.php @@ -0,0 +1,30 @@ +get('/admin', [$controller, 'index']); + $router->get('/admin/login', [$controller, 'loginForm']); + $router->get('/admin/logout', [$controller, 'logout']); + $router->get('/admin/settings', [$controller, 'settingsForm']); + $router->get('/admin/navigation', [$controller, 'navigationForm']); + $router->get('/admin/accounts', [$controller, 'accountsIndex']); + $router->get('/admin/accounts/new', [$controller, 'accountsNew']); + $router->get('/admin/updates', [$controller, 'updatesForm']); + $router->get('/admin/installer', [$controller, 'installer']); + $router->get('/admin/shortcodes', [$controller, 'shortcodesIndex']); + $router->get('/admin/shortcodes/preview', [$controller, 'shortcodesPreview']); + + $router->post('/admin/install', [$controller, 'install']); + $router->post('/admin/login', [$controller, 'login']); + $router->post('/admin/settings', [$controller, 'saveSettings']); + $router->post('/admin/navigation', [$controller, 'saveNavigation']); + $router->post('/admin/accounts/save', [$controller, 'accountsSave']); + $router->post('/admin/accounts/delete', [$controller, 'accountsDelete']); + $router->post('/admin/updates', [$controller, 'updatesSave']); +}; diff --git a/modules/admin/views/account_new.php b/modules/admin/views/account_new.php new file mode 100644 index 0000000..f343a93 --- /dev/null +++ b/modules/admin/views/account_new.php @@ -0,0 +1,45 @@ + +
+
Accounts
+
+
+

New Account

+

Create an admin, manager, or editor account.

+
+ Back +
+ + +
+ + +
+
+
+ + + + + + + + +
+
+ +
+ +
+
+
+ +
+
Accounts
+
+
+

Accounts

+

Manage admin access and roles.

+
+ New Account +
+ + +
+ + +
+
Permissions
+
+
Capability
+
Admin
+
Manager
+
Editor
+
+
+
Full access
+
+
+
+
+
+
Restricted modules
+
+
+
+
+
+
Edit pages
+
+
+
+
+
+ +
+
+
Name
+
Email
+
Role
+
Actions
+
+ +
No accounts yet.
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
Automation
+
+
+

Cron Jobs

+

Cron jobs run server tasks in the background. Use them when tasks must run reliably without waiting for page visits.

+
+
+ +
+
+
Support IMAP Sync: Required if you want email replies imported into tickets.
+
Store Sales Chart: Recommended for predictable chart refresh and lower request-time work.
+
+
+ + +
+
Enable Store and/or Support plugin to show cron commands here.
+
+ +
+ + +
+
+
+
+
+
+ +
+ +
+ +
+ $v): ?> + + + +
+
+ + + +
+ + +
+
+ +
+ +
+ + + +
+
Admin
+

Welcome

+

Admin module is live. Use Settings to update the footer text.

+
+ Settings +
+
+ +
+
Setup
+

AudioCore V1.5 Installer

+

Deploy a fresh instance with validated SMTP and baseline health checks.

+ +
+
+ 1. Core Setup +
+
+ 2. Site + SMTP +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+ Debug output +
+
+ +
+ + + +
+
Installer Health Checks
+
+ +
+ + + +
+
+
+
+
+ +
+
+ + +
+ + +
+
+ +
+ false, 'update_available' => false]; +if ($isAuthed) { + try { + $updateStatus = Updater::getStatus(false); + } catch (Throwable $e) { + $updateStatus = ['ok' => false, 'update_available' => false]; + } +} +?> + + + + + + <?= htmlspecialchars($pageTitle ?? 'Admin', ENT_QUOTES, 'UTF-8') ?> + + + + + + + + + +
+
+ + + + +
+ +
+
+
+
+
AC
+
+
AudioCore Admin
+
V1.5
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+ + + + diff --git a/modules/admin/views/login.php b/modules/admin/views/login.php new file mode 100644 index 0000000..29fcb17 --- /dev/null +++ b/modules/admin/views/login.php @@ -0,0 +1,26 @@ + +
+
Admin
+

Admin Login

+

Sign in to manage AudioCore.

+ +

+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
Navigation
+

Site Navigation

+

Build your main menu. Add items on the left, then drag to reorder.

+ + +
+ +
Navigation saved.
+ + + + +
+ + +
+ +
+
+
+ + + + +
+
Settings
+

Site Settings

+

Configure branding, maintenance, icons, and integrations.

+ +
+ +
+ + +
+
+ + + + + + + + + + +
+ +
+
+
Branding
+
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
Use class only (or paste full <i ...> and it will be normalized).
+
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + + No logo set + +
+
+ + +
+
+
+
Upload Logo
+ +
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + !empty($c['enabled']))); +$disabledCodes = array_values(array_filter($codes, static fn(array $c): bool => empty($c['enabled']))); +ob_start(); +?> +
+
Content
+
+
+

Shortcodes

+

Use these in page HTML to render dynamic blocks from modules/plugins.

+
+ Back to pages +
+ +
+
+
Active Shortcodes
+ +
No active shortcodes found.
+ +
+ +
+
+
+ +
+ Enabled +
+
+ +
+
+ + + +
+ + +
+
+
Source:
+
+ +
+ +
+ + +
+
Disabled (plugin/module unavailable)
+
+ +
+ + +
+ +
+
+ +
+
+ + + + + +
+
System
+
+
+

Updates

+

Check for new AudioCore releases from your Gitea manifest.

+
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
Installed
+
+
+
+
Latest
+
+
+
+
Status
+ +
Update available
+ +
Up to date
+ +
Check failed
+ +
+
+
Channel
+
+
+
+ +
+ +
+ Last checked: +
+ +
+ View changelog +
+ +
+ +
+ +
Update Source
+
+ + +
+
+ +
+
+
+view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + return new Response($this->view->render('site/index.php', [ + 'title' => 'Artists', + ])); + } + + public function show(): Response + { + return new Response($this->view->render('site/show.php', [ + 'title' => 'Artist Profile', + ])); + } +} diff --git a/modules/artists/module.php b/modules/artists/module.php new file mode 100644 index 0000000..92643ac --- /dev/null +++ b/modules/artists/module.php @@ -0,0 +1,13 @@ +get('/artists', [$controller, 'index']); + $router->get('/artist', [$controller, 'show']); +}; diff --git a/modules/artists/views/site/index.php b/modules/artists/views/site/index.php new file mode 100644 index 0000000..d93287c --- /dev/null +++ b/modules/artists/views/site/index.php @@ -0,0 +1,12 @@ + +
+
Artists
+

Artists

+

Artist module placeholder.

+
+ +
+
Artist
+

Artist profile

+

Artist profile placeholder.

+
+view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + $db = Database::get(); + $posts = []; + $page = null; + if ($db instanceof PDO) { + $pageStmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE is_blog_index = 1 AND is_published = 1 LIMIT 1"); + $pageStmt->execute(); + $page = $pageStmt->fetch(PDO::FETCH_ASSOC) ?: null; + + $stmt = $db->prepare(" + SELECT title, slug, excerpt, published_at, featured_image_url, author_name, category, tags + FROM ac_posts + WHERE is_published = 1 + ORDER BY COALESCE(published_at, created_at) DESC + "); + $stmt->execute(); + $posts = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + return new Response($this->view->render('site/index.php', [ + 'title' => 'News', + 'posts' => $posts, + 'page' => $page, + ])); + } + + public function show(): Response + { + $slug = trim((string)($_GET['slug'] ?? '')); + if ($slug === '') { + return $this->notFound(); + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return $this->notFound(); + } + + $stmt = $db->prepare(" + SELECT title, content_html, published_at, featured_image_url, author_name, category, tags + FROM ac_posts + WHERE slug = :slug AND is_published = 1 + LIMIT 1 + "); + $stmt->execute([':slug' => $slug]); + $post = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$post) { + return $this->notFound(); + } + + return new Response($this->view->render('site/show.php', [ + 'title' => (string)$post['title'], + 'content_html' => (string)$post['content_html'], + 'published_at' => (string)($post['published_at'] ?? ''), + 'featured_image_url' => (string)($post['featured_image_url'] ?? ''), + 'author_name' => (string)($post['author_name'] ?? ''), + 'category' => (string)($post['category'] ?? ''), + 'tags' => (string)($post['tags'] ?? ''), + ])); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + $posts = []; + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, title, slug, author_name, is_published, published_at, updated_at FROM ac_posts ORDER BY updated_at DESC"); + $posts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Posts', + 'posts' => $posts, + ])); + } + + public function adminEdit(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + $post = [ + 'id' => 0, + 'title' => '', + 'slug' => '', + 'excerpt' => '', + 'featured_image_url' => '', + 'author_name' => '', + 'category' => '', + 'tags' => '', + 'content_html' => '', + 'is_published' => 0, + 'published_at' => '', + ]; + + $db = Database::get(); + if ($id > 0 && $db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_posts WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $post = $row; + } + } + + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Post' : 'New Post', + 'post' => $post, + 'error' => '', + ])); + } + + public function adminSave(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/posts']); + } + + $id = (int)($_POST['id'] ?? 0); + $title = trim((string)($_POST['title'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + $excerpt = trim((string)($_POST['excerpt'] ?? '')); + $featuredImage = trim((string)($_POST['featured_image_url'] ?? '')); + $authorName = trim((string)($_POST['author_name'] ?? '')); + $category = trim((string)($_POST['category'] ?? '')); + $tags = trim((string)($_POST['tags'] ?? '')); + $content = (string)($_POST['content_html'] ?? ''); + $isPublished = isset($_POST['is_published']) ? 1 : 0; + $publishedAt = trim((string)($_POST['published_at'] ?? '')); + + if ($title === '') { + return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Title is required.'); + } + + if ($slug === '') { + $slug = $this->slugify($title); + } else { + $slug = $this->slugify($slug); + } + + if ($publishedAt !== '') { + try { + $dt = new DateTime($publishedAt); + $publishedAt = $dt->format('Y-m-d H:i:s'); + } catch (Throwable $e) { + $publishedAt = ''; + } + } + + try { + if ($id > 0) { + $chk = $db->prepare("SELECT id FROM ac_posts WHERE slug = :slug AND id != :id LIMIT 1"); + $chk->execute([':slug' => $slug, ':id' => $id]); + } else { + $chk = $db->prepare("SELECT id FROM ac_posts WHERE slug = :slug LIMIT 1"); + $chk->execute([':slug' => $slug]); + } + if ($chk->fetch()) { + return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Slug already exists.'); + } + + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_posts + SET title = :title, slug = :slug, excerpt = :excerpt, + featured_image_url = :featured_image_url, author_name = :author_name, + category = :category, tags = :tags, content_html = :content, + is_published = :published, published_at = :published_at + WHERE id = :id + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':excerpt' => $excerpt !== '' ? $excerpt : null, + ':featured_image_url' => $featuredImage !== '' ? $featuredImage : null, + ':author_name' => $authorName !== '' ? $authorName : null, + ':category' => $category !== '' ? $category : null, + ':tags' => $tags !== '' ? $tags : null, + ':content' => $content, + ':published' => $isPublished, + ':published_at' => $publishedAt !== '' ? $publishedAt : null, + ':id' => $id, + ]); + } else { + $stmt = $db->prepare(" + INSERT INTO ac_posts (title, slug, excerpt, featured_image_url, author_name, category, tags, content_html, is_published, published_at) + VALUES (:title, :slug, :excerpt, :featured_image_url, :author_name, :category, :tags, :content, :published, :published_at) + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':excerpt' => $excerpt !== '' ? $excerpt : null, + ':featured_image_url' => $featuredImage !== '' ? $featuredImage : null, + ':author_name' => $authorName !== '' ? $authorName : null, + ':category' => $category !== '' ? $category : null, + ':tags' => $tags !== '' ? $tags : null, + ':content' => $content, + ':published' => $isPublished, + ':published_at' => $publishedAt !== '' ? $publishedAt : null, + ]); + } + } catch (Throwable $e) { + return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Unable to save post.'); + } + + return new Response('', 302, ['Location' => '/admin/posts']); + } + + public function adminDelete(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/posts']); + } + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + $stmt = $db->prepare("DELETE FROM ac_posts WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/posts']); + } + + private function renderEditError( + int $id, + string $title, + string $slug, + string $excerpt, + string $featuredImage, + string $authorName, + string $category, + string $tags, + string $content, + int $isPublished, + string $publishedAt, + string $error + ): Response { + $post = [ + 'id' => $id, + 'title' => $title, + 'slug' => $slug, + 'excerpt' => $excerpt, + 'featured_image_url' => $featuredImage, + 'author_name' => $authorName, + 'category' => $category, + 'tags' => $tags, + 'content_html' => $content, + 'is_published' => $isPublished, + 'published_at' => $publishedAt, + ]; + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Post' : 'New Post', + 'post' => $post, + 'error' => $error, + ])); + } + + private function notFound(): Response + { + $view = new View(); + return new Response($view->render('site/404.php', [ + 'title' => 'Not Found', + 'message' => 'Post not found.', + ]), 404); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'post'; + } + + private function guard(array $roles): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole($roles)) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } +} diff --git a/modules/blog/module.php b/modules/blog/module.php new file mode 100644 index 0000000..83e3c31 --- /dev/null +++ b/modules/blog/module.php @@ -0,0 +1,19 @@ +get('/news', [$controller, 'index']); + $router->get('/news/post', [$controller, 'show']); + + $router->get('/admin/posts', [$controller, 'adminIndex']); + $router->get('/admin/posts/new', [$controller, 'adminEdit']); + $router->get('/admin/posts/edit', [$controller, 'adminEdit']); + $router->post('/admin/posts/save', [$controller, 'adminSave']); + $router->post('/admin/posts/delete', [$controller, 'adminDelete']); +}; diff --git a/modules/blog/views/admin/edit.php b/modules/blog/views/admin/edit.php new file mode 100644 index 0000000..18c1635 --- /dev/null +++ b/modules/blog/views/admin/edit.php @@ -0,0 +1,67 @@ + +
+
Blog
+
+
+

+

Write a news post or update.

+
+ Back +
+ + +
+ + +
+ +
+
+ + + + + + + + + + + + + + +
+ + +
+ + + + +
+
+ +
+ +
+
+ + +
+ + +
+ +
+ +
+
Blog
+
+
+

Posts

+

Publish news updates and announcements.

+
+ New Post +
+ +
+
+
Title
+
Slug
+
Author
+
Status
+
Published
+
Actions
+
+ +
No posts yet.
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Edit + View +
+
+ + +
+
+ +
+
News
+ +
+ +
+ +

Latest Updates

+

News, updates, and announcements.

+ + +
+ +
No posts yet.
+ + +
+
Post
+

+ + + + +
+ +
+ + + + + · + + +
+

+ +

+ +
+ + + + + +
+ + Read more +
+ + +
+
+ +
+
News
+

+ +
+ + + + + · + + + + · + + +
+ + + + +
+ +
+ +
+ + + + + +
+ +
+view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $db = Database::get(); + $items = []; + $folders = []; + $folderId = isset($_GET['folder']) ? (int)$_GET['folder'] : 0; + if ($db instanceof PDO) { + $folderStmt = $db->query("SELECT id, name FROM ac_media_folders ORDER BY name ASC"); + $folders = $folderStmt ? $folderStmt->fetchAll(PDO::FETCH_ASSOC) : []; + if ($folderId > 0) { + $stmt = $db->prepare("SELECT id, file_name, file_url, file_type, file_size, created_at FROM ac_media WHERE folder_id = :folder_id ORDER BY created_at DESC"); + $stmt->execute([':folder_id' => $folderId]); + } else { + $stmt = $db->query("SELECT id, file_name, file_url, file_type, file_size, created_at FROM ac_media WHERE folder_id IS NULL ORDER BY created_at DESC"); + } + $items = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } + + $error = (string)($_GET['error'] ?? ''); + $success = (string)($_GET['success'] ?? ''); + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Media', + 'items' => $items, + 'folders' => $folders, + 'active_folder' => $folderId, + 'error' => $error, + 'success' => $success, + ])); + } + + public function picker(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager', 'editor'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $db = Database::get(); + $items = []; + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, file_name, file_url, file_type FROM ac_media ORDER BY created_at DESC"); + $items = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } + + return new Response(json_encode(['items' => $items]), 200, ['Content-Type' => 'application/json']); + } + + public function upload(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $file = $_FILES['media_file'] ?? null; + $folderId = isset($_POST['folder_id']) ? (int)$_POST['folder_id'] : 0; + if (!$file || !isset($file['tmp_name'])) { + return $this->uploadError('No file uploaded.', $folderId); + } + if ($file['error'] !== UPLOAD_ERR_OK) { + return $this->uploadError($this->uploadErrorMessage((int)$file['error']), $folderId); + } + + $tmp = (string)$file['tmp_name']; + if ($tmp === '' || !is_uploaded_file($tmp)) { + return new Response('', 302, ['Location' => '/admin/media']); + } + + $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); + if ($ext === '') { + $ext = 'bin'; + } + + $uploadDir = __DIR__ . '/../../uploads/media'; + if (!is_dir($uploadDir)) { + if (!mkdir($uploadDir, 0755, true)) { + return $this->uploadError('Upload directory could not be created.', $folderId); + } + } + if (!is_writable($uploadDir)) { + return $this->uploadError('Upload directory is not writable.', $folderId); + } + + $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'file'; + $baseName = trim($baseName, '-'); + $fileName = ($baseName !== '' ? $baseName : 'file') . '-' . date('YmdHis') . '.' . $ext; + $dest = $uploadDir . '/' . $fileName; + if (!move_uploaded_file($tmp, $dest)) { + return $this->uploadError('Upload failed. Check server permissions.', $folderId); + } + + $fileUrl = '/uploads/media/' . $fileName; + $fileType = (string)($file['type'] ?? ''); + $fileSize = (int)($file['size'] ?? 0); + + $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, :folder_id) + "); + $stmt->execute([ + ':name' => (string)$file['name'], + ':url' => $fileUrl, + ':type' => $fileType, + ':size' => $fileSize, + ':folder_id' => $folderId > 0 ? $folderId : null, + ]); + } catch (Throwable $e) { + return $this->uploadError('Database insert failed.', $folderId); + } + } + + $redirect = $folderId > 0 ? '/admin/media?folder=' . $folderId . '&success=1' : '/admin/media?success=1'; + return new Response('', 302, ['Location' => $redirect]); + } + + public function delete(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $id = (int)($_POST['id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $id > 0) { + $stmt = $db->prepare("SELECT file_url FROM ac_media WHERE id = :id"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row && !empty($row['file_url'])) { + $path = __DIR__ . '/../../..' . (string)$row['file_url']; + if (is_file($path)) { + @unlink($path); + } + } + $db->prepare("DELETE FROM ac_media WHERE id = :id")->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/media']); + } + + public function createFolder(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $name = trim((string)($_POST['name'] ?? '')); + if ($name === '') { + return new Response('', 302, ['Location' => '/admin/media']); + } + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare("INSERT INTO ac_media_folders (name) VALUES (:name)"); + $stmt->execute([':name' => $name]); + } + return new Response('', 302, ['Location' => '/admin/media']); + } + + private function uploadError(string $message, int $folderId): Response + { + $target = $folderId > 0 ? '/admin/media?folder=' . $folderId : '/admin/media'; + $target .= '&error=' . rawurlencode($message); + return new Response('', 302, ['Location' => $target]); + } + + private function uploadErrorMessage(int $code): string + { + $max = (string)ini_get('upload_max_filesize'); + $map = [ + UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).", + UPLOAD_ERR_FORM_SIZE => 'File exceeds form 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 a PHP extension.', + UPLOAD_ERR_NO_FILE => 'No file uploaded.', + ]; + return $map[$code] ?? 'Upload failed.'; + } +} diff --git a/modules/media/module.php b/modules/media/module.php new file mode 100644 index 0000000..f492baa --- /dev/null +++ b/modules/media/module.php @@ -0,0 +1,16 @@ +get('/admin/media', [$controller, 'index']); + $router->get('/admin/media/picker', [$controller, 'picker']); + $router->post('/admin/media/upload', [$controller, 'upload']); + $router->post('/admin/media/delete', [$controller, 'delete']); + $router->post('/admin/media/folders', [$controller, 'createFolder']); +}; diff --git a/modules/media/views/admin/index.php b/modules/media/views/admin/index.php new file mode 100644 index 0000000..ba1b974 --- /dev/null +++ b/modules/media/views/admin/index.php @@ -0,0 +1,114 @@ + +
+
Media
+
+
+

Media Library

+

Upload and reuse images across pages, posts, and newsletters.

+
+
+ + +
+ +
Upload complete.
+ + +
+
+ All + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+
+
+ +
+ +
No media uploaded yet.
+ + +
+
+ + + +
File
+ +
+
+ +
+ +
+ + +
+
+ + +
+
+ +view = new View(__DIR__ . '/views'); + } + + public function subscribe(): Response + { + $email = trim((string)($_POST['email'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + if ($email === '') { + return new Response('Missing email', 400); + } + + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare(" + INSERT INTO ac_newsletter_subscribers (email, name, status, source) + VALUES (:email, :name, 'subscribed', 'form') + ON DUPLICATE KEY UPDATE name = VALUES(name), status = 'subscribed', unsubscribed_at = NULL + "); + $stmt->execute([ + ':email' => $email, + ':name' => $name !== '' ? $name : null, + ]); + } + + $this->syncMailchimp($email, $name); + + return new Response('Subscribed', 200); + } + + public function unsubscribeForm(): Response + { + $email = trim((string)($_GET['email'] ?? '')); + return new Response($this->view->render('site/unsubscribe.php', [ + 'title' => 'Unsubscribe', + 'email' => $email, + 'status' => '', + ])); + } + + public function unsubscribe(): Response + { + $email = trim((string)($_POST['email'] ?? '')); + $status = 'Email is required.'; + + $db = Database::get(); + if ($db instanceof PDO && $email !== '') { + $stmt = $db->prepare("UPDATE ac_newsletter_subscribers SET status = 'unsubscribed', unsubscribed_at = NOW() WHERE email = :email"); + $stmt->execute([':email' => $email]); + $status = 'You have been unsubscribed.'; + } + + return new Response($this->view->render('site/unsubscribe.php', [ + 'title' => 'Unsubscribe', + 'email' => $email, + 'status' => $status, + ])); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + $campaigns = []; + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, title, subject, status, sent_at, scheduled_at FROM ac_newsletter_campaigns ORDER BY created_at DESC"); + $campaigns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Newsletter', + 'campaigns' => $campaigns, + ])); + } + + public function adminSubscribers(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + $subscribers = []; + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, email, name, status, created_at FROM ac_newsletter_subscribers ORDER BY created_at DESC"); + $subscribers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } + return new Response($this->view->render('admin/subscribers.php', [ + 'title' => 'Newsletter', + 'subscribers' => $subscribers, + ])); + } + + public function adminEdit(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + $campaign = [ + 'id' => 0, + 'title' => '', + 'subject' => '', + 'content_html' => '', + 'status' => 'draft', + 'scheduled_at' => '', + ]; + $db = Database::get(); + if ($id > 0 && $db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $campaign = $row; + } + } + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign', + 'campaign' => $campaign, + 'error' => '', + ])); + } + + public function adminSave(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + $id = (int)($_POST['id'] ?? 0); + $title = trim((string)($_POST['title'] ?? '')); + $subject = trim((string)($_POST['subject'] ?? '')); + $content = (string)($_POST['content_html'] ?? ''); + $scheduledAt = trim((string)($_POST['scheduled_at'] ?? '')); + + if ($title === '' || $subject === '') { + return $this->renderEditError($id, $title, $subject, $content, 'Title and subject are required.'); + } + + try { + $status = $scheduledAt !== '' ? 'scheduled' : 'draft'; + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_newsletter_campaigns + SET title = :title, subject = :subject, content_html = :content, + status = :status, scheduled_at = :scheduled_at + WHERE id = :id + "); + $stmt->execute([ + ':title' => $title, + ':subject' => $subject, + ':content' => $content, + ':status' => $status, + ':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null, + ':id' => $id, + ]); + } else { + $stmt = $db->prepare(" + INSERT INTO ac_newsletter_campaigns (title, subject, content_html, status, scheduled_at) + VALUES (:title, :subject, :content, :status, :scheduled_at) + "); + $stmt->execute([ + ':title' => $title, + ':subject' => $subject, + ':content' => $content, + ':status' => $status, + ':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null, + ]); + } + } catch (Throwable $e) { + return $this->renderEditError($id, $title, $subject, $content, 'Unable to save campaign.'); + } + + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + public function adminSend(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + $id = (int)($_POST['id'] ?? 0); + $stmt = $db->prepare("SELECT id, subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $campaign = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$campaign) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + $subStmt = $db->query("SELECT id, email FROM ac_newsletter_subscribers WHERE status = 'subscribed'"); + $subs = $subStmt ? $subStmt->fetchAll(PDO::FETCH_ASSOC) : []; + + $settings = [ + 'smtp_host' => Settings::get('smtp_host'), + 'smtp_port' => Settings::get('smtp_port'), + 'smtp_user' => Settings::get('smtp_user'), + 'smtp_pass' => Settings::get('smtp_pass'), + 'smtp_encryption' => Settings::get('smtp_encryption'), + 'smtp_from_email' => Settings::get('smtp_from_email'), + 'smtp_from_name' => Settings::get('smtp_from_name'), + ]; + + foreach ($subs as $sub) { + $result = Mailer::send((string)$sub['email'], (string)$campaign['subject'], (string)$campaign['content_html'], $settings); + $sendStmt = $db->prepare(" + INSERT INTO ac_newsletter_sends (campaign_id, subscriber_id, status, sent_at, error_text) + VALUES (:campaign_id, :subscriber_id, :status, NOW(), :error_text) + "); + $sendStmt->execute([ + ':campaign_id' => (int)$campaign['id'], + ':subscriber_id' => (int)$sub['id'], + ':status' => $result['ok'] ? 'sent' : 'failed', + ':error_text' => $result['ok'] ? null : (string)$result['error'], + ]); + } + + $db->prepare("UPDATE ac_newsletter_campaigns SET status = 'sent', sent_at = NOW() WHERE id = :id") + ->execute([':id' => $id]); + + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + public function adminTestSend(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + $id = (int)($_POST['id'] ?? 0); + $email = trim((string)($_POST['test_email'] ?? '')); + if ($id <= 0 || $email === '') { + return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]); + } + $stmt = $db->prepare("SELECT subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $campaign = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$campaign) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + $settings = [ + 'smtp_host' => Settings::get('smtp_host'), + 'smtp_port' => Settings::get('smtp_port'), + 'smtp_user' => Settings::get('smtp_user'), + 'smtp_pass' => Settings::get('smtp_pass'), + 'smtp_encryption' => Settings::get('smtp_encryption'), + 'smtp_from_email' => Settings::get('smtp_from_email'), + 'smtp_from_name' => Settings::get('smtp_from_name'), + ]; + + Mailer::send($email, (string)$campaign['subject'], (string)$campaign['content_html'], $settings); + + return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]); + } + + public function adminProcessQueue(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + $stmt = $db->prepare(" + SELECT id FROM ac_newsletter_campaigns + WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW() + "); + $stmt->execute(); + $campaigns = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($campaigns as $campaign) { + $_POST['id'] = (int)$campaign['id']; + $this->adminSend(); + } + + return new Response('', 302, ['Location' => '/admin/newsletter']); + } + + public function adminDeleteSubscriber(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $id = (int)($_POST['id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $id > 0) { + $db->prepare("DELETE FROM ac_newsletter_subscribers WHERE id = :id")->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/newsletter/subscribers']); + } + + private function renderEditError(int $id, string $title, string $subject, string $content, string $error): Response + { + $campaign = [ + 'id' => $id, + 'title' => $title, + 'subject' => $subject, + 'content_html' => $content, + ]; + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign', + 'campaign' => $campaign, + 'error' => $error, + ])); + } + + private function guard(array $roles): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole($roles)) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } + + private function syncMailchimp(string $email, string $name): void + { + $apiKey = Settings::get('mailchimp_api_key'); + $listId = Settings::get('mailchimp_list_id'); + if ($apiKey === '' || $listId === '') { + return; + } + $parts = explode('-', $apiKey); + $dc = $parts[1] ?? ''; + if ($dc === '') { + return; + } + + $subscriberHash = md5(strtolower($email)); + $url = "https://{$dc}.api.mailchimp.com/3.0/lists/{$listId}/members/{$subscriberHash}"; + $payload = json_encode([ + 'email_address' => $email, + 'status' => 'subscribed', + 'merge_fields' => [ + 'FNAME' => $name, + ], + ]); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERPWD, 'user:' . $apiKey); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_exec($ch); + curl_close($ch); + } +} diff --git a/modules/newsletter/module.php b/modules/newsletter/module.php new file mode 100644 index 0000000..88ed648 --- /dev/null +++ b/modules/newsletter/module.php @@ -0,0 +1,80 @@ +' + . '
' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . '' + . '' + . '
' + . ''; +}); + +Shortcodes::register('newsletter-unsubscribe', static function (array $attrs = []): string { + $label = trim((string)($attrs['label'] ?? 'Unsubscribe')); + if ($label === '') { + $label = 'Unsubscribe'; + } + $token = trim((string)($attrs['token'] ?? '')); + $href = '/newsletter/unsubscribe'; + if ($token !== '') { + $href .= '?token=' . rawurlencode($token); + } + return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; +}); + +Shortcodes::register('newsletter-unsubscribe-form', static function (array $attrs = []): string { + $title = trim((string)($attrs['title'] ?? 'Unsubscribe from newsletter')); + $button = trim((string)($attrs['button'] ?? 'Unsubscribe')); + if ($title === '') { + $title = 'Unsubscribe from newsletter'; + } + if ($button === '') { + $button = 'Unsubscribe'; + } + return '
' + . '
' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '
' + . '
' + . '' + . '' + . '
' + . '
'; +}); + +return function (Router $router): void { + $controller = new NewsletterController(); + + $router->post('/newsletter/subscribe', [$controller, 'subscribe']); + $router->get('/newsletter/unsubscribe', [$controller, 'unsubscribeForm']); + $router->post('/newsletter/unsubscribe', [$controller, 'unsubscribe']); + + $router->get('/admin/newsletter', [$controller, 'adminIndex']); + $router->get('/admin/newsletter/campaigns/new', [$controller, 'adminEdit']); + $router->get('/admin/newsletter/campaigns/edit', [$controller, 'adminEdit']); + $router->post('/admin/newsletter/campaigns/save', [$controller, 'adminSave']); + $router->post('/admin/newsletter/campaigns/send', [$controller, 'adminSend']); + $router->post('/admin/newsletter/campaigns/test', [$controller, 'adminTestSend']); + $router->post('/admin/newsletter/campaigns/process', [$controller, 'adminProcessQueue']); + $router->get('/admin/newsletter/subscribers', [$controller, 'adminSubscribers']); + $router->post('/admin/newsletter/subscribers/delete', [$controller, 'adminDeleteSubscriber']); +}; diff --git a/modules/newsletter/views/admin/edit.php b/modules/newsletter/views/admin/edit.php new file mode 100644 index 0000000..1737acf --- /dev/null +++ b/modules/newsletter/views/admin/edit.php @@ -0,0 +1,96 @@ + +
+
Newsletter
+
+
+

+

Write a full HTML campaign.

+
+ Back +
+ + +
+ + +
+ +
+
+ + + + +
+ + +
+ + + +
+
+ +
+ + +
+
+ + +
+ + + +
+ +
+ + +
+
Newsletter
+
+
+

Campaigns

+

Build and send HTML newsletters.

+
+
+ Subscribers +
+ +
+ New Campaign +
+
+ +
+
+
Title
+
Subject
+
Status
+
Scheduled
+
Sent At
+
Actions
+
+ +
No campaigns yet.
+ + +
+
+
+
+ +
+
+
+
+ Edit +
+ + +
+
+
+ + +
+
+ +
+
Signup Form
+

Choose a signup form template to paste into any custom page.

+
+ +
+
+ +
+
+
+ +
+
Template Starter
+

Pick a campaign template, preview it, then copy HTML.

+
+ +
+
+ +
+
+
+ + + +
+
Newsletter
+
+
+

Subscribers

+

People on your newsletter list.

+
+ Back to Campaigns +
+ +
+
+
Email
+
Name
+
Status
+
Joined
+
Actions
+
+ +
No subscribers yet.
+ + +
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
Newsletter
+

Unsubscribe

+

Remove your email from the newsletter list.

+ + +
+ + +
+ + +
+
+view = new View(__DIR__ . '/views'); + } + + public function show(): Response + { + $slug = trim((string)($_GET['slug'] ?? '')); + if ($slug === '') { + return $this->notFound(); + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return $this->notFound(); + } + + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1"); + $stmt->execute([':slug' => $slug]); + $page = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$page) { + return $this->notFound(); + } + + return new Response($this->view->render('site/show.php', [ + 'title' => (string)$page['title'], + 'content_html' => Shortcodes::render((string)$page['content_html'], [ + 'page_slug' => $slug, + 'page_title' => (string)$page['title'], + ]), + ])); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard(['admin', 'manager', 'editor'])) { + return $guard; + } + + $db = Database::get(); + $pages = []; + if ($db instanceof PDO) { + try { + $stmt = $db->query("SELECT id, title, slug, is_published, is_home, is_blog_index, updated_at FROM ac_pages ORDER BY updated_at DESC"); + $pages = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $pages = []; + } + } + + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Pages', + 'pages' => $pages, + ])); + } + + public function adminEdit(): Response + { + if ($guard = $this->guard(['admin', 'manager', 'editor'])) { + return $guard; + } + + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + $page = [ + 'id' => 0, + 'title' => '', + 'slug' => '', + 'content_html' => '', + 'is_published' => 0, + 'is_home' => 0, + 'is_blog_index' => 0, + ]; + + $db = Database::get(); + if ($id > 0 && $db instanceof PDO) { + $stmt = $db->prepare("SELECT id, title, slug, content_html, is_published, is_home, is_blog_index FROM ac_pages WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $page = $row; + } + } + + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Page' : 'New Page', + 'page' => $page, + 'error' => '', + ])); + } + + public function adminSave(): Response + { + if ($guard = $this->guard(['admin', 'manager', 'editor'])) { + return $guard; + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/pages']); + } + + $id = (int)($_POST['id'] ?? 0); + $title = trim((string)($_POST['title'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + $content = (string)($_POST['content_html'] ?? ''); + $isPublished = isset($_POST['is_published']) ? 1 : 0; + $isHome = isset($_POST['is_home']) ? 1 : 0; + $isBlogIndex = isset($_POST['is_blog_index']) ? 1 : 0; + + if ($title === '') { + return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Title is required.'); + } + + if ($slug === '') { + $slug = $this->slugify($title); + } else { + $slug = $this->slugify($slug); + } + + try { + if ($id > 0) { + $chk = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug AND id != :id LIMIT 1"); + $chk->execute([':slug' => $slug, ':id' => $id]); + } else { + $chk = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1"); + $chk->execute([':slug' => $slug]); + } + if ($chk->fetch()) { + return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Slug already exists.'); + } + + if ($isHome === 1) { + $db->exec("UPDATE ac_pages SET is_home = 0"); + } + if ($isBlogIndex === 1) { + $db->exec("UPDATE ac_pages SET is_blog_index = 0"); + } + + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_pages + SET title = :title, slug = :slug, content_html = :content, + is_published = :published, is_home = :is_home, is_blog_index = :is_blog_index + WHERE id = :id + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':content' => $content, + ':published' => $isPublished, + ':is_home' => $isHome, + ':is_blog_index' => $isBlogIndex, + ':id' => $id, + ]); + } else { + $stmt = $db->prepare(" + INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index) + VALUES (:title, :slug, :content, :published, :is_home, :is_blog_index) + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':content' => $content, + ':published' => $isPublished, + ':is_home' => $isHome, + ':is_blog_index' => $isBlogIndex, + ]); + } + } catch (Throwable $e) { + return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Unable to save page.'); + } + + return new Response('', 302, ['Location' => '/admin/pages']); + } + + public function adminDelete(): Response + { + if ($guard = $this->guard(['admin', 'manager'])) { + return $guard; + } + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/pages']); + } + $id = (int)($_POST['id'] ?? 0); + if ($id > 0) { + $stmt = $db->prepare("DELETE FROM ac_pages WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/pages']); + } + + private function renderEditError(int $id, string $title, string $slug, string $content, int $isPublished, int $isHome, int $isBlogIndex, string $error): Response + { + $page = [ + 'id' => $id, + 'title' => $title, + 'slug' => $slug, + 'content_html' => $content, + 'is_published' => $isPublished, + 'is_home' => $isHome, + 'is_blog_index' => $isBlogIndex, + ]; + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Page' : 'New Page', + 'page' => $page, + 'error' => $error, + ])); + } + + private function notFound(): Response + { + $view = new View(); + return new Response($view->render('site/404.php', [ + 'title' => 'Not Found', + 'message' => 'Page not found.', + ]), 404); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'page'; + } + + private function guard(array $roles): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole($roles)) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } +} diff --git a/modules/pages/module.php b/modules/pages/module.php new file mode 100644 index 0000000..75490f3 --- /dev/null +++ b/modules/pages/module.php @@ -0,0 +1,18 @@ +get('/page', [$controller, 'show']); + + $router->get('/admin/pages', [$controller, 'adminIndex']); + $router->get('/admin/pages/new', [$controller, 'adminEdit']); + $router->get('/admin/pages/edit', [$controller, 'adminEdit']); + $router->post('/admin/pages/save', [$controller, 'adminSave']); + $router->post('/admin/pages/delete', [$controller, 'adminDelete']); +}; diff --git a/modules/pages/views/admin/edit.php b/modules/pages/views/admin/edit.php new file mode 100644 index 0000000..11ab923 --- /dev/null +++ b/modules/pages/views/admin/edit.php @@ -0,0 +1,122 @@ + +
+
Pages
+
+
+

+

Create a custom page for the site.

+
+ Back +
+ + +
+ + +
+ +
+
+ + + + +
+ + +
+ + + + +
+
+ +
+ + +
+
+ + +
+ + +
+ +
+ + +
+
Home
+
+
+

Home Page

+

Edit the front page content. Leave title blank to hide it.

+
+ View site +
+ + +
Home page updated.
+ + +
+
+
+ + + + + +
+
+ +
+ +
+
+
+ +
+
Pages
+
+
+

Pages

+

Manage custom content pages.

+
+ New Page +
+ +
+
+
Title
+
Slug
+
Status
+
Home
+
Blog
+
Updated
+
Actions
+
+ +
No pages yet.
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ Edit + View +
+
+ + +
+
+ +
+
+ +
+
+view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + Plugins::sync(); + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Plugins', + 'plugins' => Plugins::all(), + ])); + } + + public function toggle(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $slug = trim((string)($_POST['slug'] ?? '')); + $enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1'; + if ($slug !== '') { + Plugins::toggle($slug, $enabled); + } + return new Response('', 302, ['Location' => '/admin/plugins']); + } + + private function guard(): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin'])) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } +} diff --git a/modules/plugins/module.php b/modules/plugins/module.php new file mode 100644 index 0000000..2a23d7c --- /dev/null +++ b/modules/plugins/module.php @@ -0,0 +1,13 @@ +get('/admin/plugins', [$controller, 'index']); + $router->post('/admin/plugins/toggle', [$controller, 'toggle']); +}; diff --git a/modules/plugins/views/admin/index.php b/modules/plugins/views/admin/index.php new file mode 100644 index 0000000..02cd9ca --- /dev/null +++ b/modules/plugins/views/admin/index.php @@ -0,0 +1,55 @@ + +
+
Plugins
+
+
+

Plugins

+

Enable or disable optional features.

+
+
+ + +
No plugins found in /dev/plugins.
+ +
+ +
+
+
+
+ +
+
+ +
+
+ Slug: + Version: + + Author: + +
+
+
+ + + + + + + + +
+
+
+ +
+ +
+view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + $db = Database::get(); + $page = null; + $artists = []; + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = 'artists' AND is_published = 1 LIMIT 1"); + $stmt->execute(); + $page = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + $listStmt = $db->query(" + SELECT id, name, slug, country, avatar_url + FROM ac_artists + WHERE is_active = 1 + ORDER BY created_at DESC + "); + $artists = $listStmt ? $listStmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + } + } + + return new Response($this->view->render('site/index.php', [ + 'title' => (string)($page['title'] ?? 'Artists'), + 'content_html' => (string)($page['content_html'] ?? ''), + 'artists' => $artists, + ])); + } + + public function show(): Response + { + $slug = trim((string)($_GET['slug'] ?? '')); + $artist = null; + $artistReleases = []; + if ($slug !== '') { + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_artists WHERE slug = :slug AND is_active = 1 LIMIT 1"); + $stmt->execute([':slug' => $slug]); + $artist = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + if ($artist) { + try { + $artistIdReady = false; + $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); + if ($probe && $probe->fetch(PDO::FETCH_ASSOC)) { + $artistIdReady = true; + } + + if ($artistIdReady) { + $relStmt = $db->prepare(" + SELECT id, title, slug, release_date, cover_url, artist_name + FROM ac_releases + WHERE is_published = 1 + AND (artist_id = :artist_id OR artist_name = :artist_name) + ORDER BY release_date DESC, created_at DESC + LIMIT 2 + "); + $relStmt->execute([ + ':artist_id' => (int)($artist['id'] ?? 0), + ':artist_name' => (string)($artist['name'] ?? ''), + ]); + } else { + $relStmt = $db->prepare(" + SELECT id, title, slug, release_date, cover_url, artist_name + FROM ac_releases + WHERE is_published = 1 + AND artist_name = :artist_name + ORDER BY release_date DESC, created_at DESC + LIMIT 2 + "); + $relStmt->execute([ + ':artist_name' => (string)($artist['name'] ?? ''), + ]); + } + + $artistReleases = $relStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $artistReleases = []; + } + } + } + } + return new Response($this->view->render('site/show.php', [ + 'title' => $artist ? (string)$artist['name'] : 'Artist Profile', + 'artist' => $artist, + 'artist_releases' => $artistReleases, + ])); + } + + public function adminIndex(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $tableReady = $this->artistsTableReady(); + $socialReady = $this->socialColumnReady(); + $artists = []; + $pageId = 0; + $pagePublished = 0; + if ($tableReady) { + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, name, slug, country, avatar_url, is_active FROM ac_artists ORDER BY created_at DESC"); + $artists = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + $pageStmt = $db->prepare("SELECT id, is_published FROM ac_pages WHERE slug = 'artists' LIMIT 1"); + $pageStmt->execute(); + $pageRow = $pageStmt->fetch(PDO::FETCH_ASSOC); + if ($pageRow) { + $pageId = (int)($pageRow['id'] ?? 0); + $pagePublished = (int)($pageRow['is_published'] ?? 0); + } + } + } + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Artists', + 'table_ready' => $tableReady, + 'social_ready' => $socialReady, + 'artists' => $artists, + 'page_id' => $pageId, + 'page_published' => $pagePublished, + ])); + } + + public function adminNew(): Response + { + return $this->adminEdit(0); + } + + public function adminEdit(int $id = 0): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $artist = [ + 'id' => 0, + 'name' => '', + 'slug' => '', + 'bio' => '', + 'credits' => '', + 'country' => '', + 'avatar_url' => '', + 'social_links' => '', + 'is_active' => 1, + ]; + if ($id > 0) { + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_artists WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $artist = array_merge($artist, $row); + } + } + } + if (!empty($_GET['avatar_url']) && $artist['avatar_url'] === '') { + $artist['avatar_url'] = (string)$_GET['avatar_url']; + } + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Artist' : 'New Artist', + 'artist' => $artist, + 'error' => '', + ])); + } + + public function adminSave(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $id = (int)($_POST['id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + $bio = trim((string)($_POST['bio'] ?? '')); + $credits = trim((string)($_POST['credits'] ?? '')); + $country = trim((string)($_POST['country'] ?? '')); + $avatarUrl = trim((string)($_POST['avatar_url'] ?? '')); + $socialLinks = [ + 'website' => $this->normalizeUrl(trim((string)($_POST['social_website'] ?? ''))), + 'instagram' => $this->normalizeUrl(trim((string)($_POST['social_instagram'] ?? ''))), + 'soundcloud' => $this->normalizeUrl(trim((string)($_POST['social_soundcloud'] ?? ''))), + 'spotify' => $this->normalizeUrl(trim((string)($_POST['social_spotify'] ?? ''))), + 'youtube' => $this->normalizeUrl(trim((string)($_POST['social_youtube'] ?? ''))), + 'tiktok' => $this->normalizeUrl(trim((string)($_POST['social_tiktok'] ?? ''))), + 'bandcamp' => $this->normalizeUrl(trim((string)($_POST['social_bandcamp'] ?? ''))), + 'beatport' => $this->normalizeUrl(trim((string)($_POST['social_beatport'] ?? ''))), + 'facebook' => $this->normalizeUrl(trim((string)($_POST['social_facebook'] ?? ''))), + 'x' => $this->normalizeUrl(trim((string)($_POST['social_x'] ?? ''))), + ]; + $socialLinks = array_filter($socialLinks, static fn($value) => $value !== ''); + $socialJson = $socialLinks ? json_encode($socialLinks, JSON_UNESCAPED_SLASHES) : null; + $socialReady = $this->socialColumnReady(); + $creditsReady = $this->creditsColumnReady(); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($name === '') { + return $this->saveError($id, 'Name is required.'); + } + $slug = $slug !== '' ? $this->slugify($slug) : $this->slugify($name); + + $db = Database::get(); + if (!$db instanceof PDO) { + return $this->saveError($id, 'Database unavailable.'); + } + + $dupStmt = $id > 0 + ? $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug AND id != :id LIMIT 1") + : $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug LIMIT 1"); + $params = $id > 0 ? [':slug' => $slug, ':id' => $id] : [':slug' => $slug]; + $dupStmt->execute($params); + if ($dupStmt->fetch()) { + return $this->saveError($id, 'Slug already exists.'); + } + + try { + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_artists + SET name = :name, slug = :slug, bio = :bio" . ($creditsReady ? ", credits = :credits" : "") . ", country = :country, + avatar_url = :avatar_url" . ($socialReady ? ", social_links = :social_links" : "") . ", + is_active = :is_active + WHERE id = :id + "); + $params = [ + ':name' => $name, + ':slug' => $slug, + ':bio' => $bio !== '' ? $bio : null, + ':country' => $country !== '' ? $country : null, + ':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null, + ':is_active' => $isActive, + ':id' => $id, + ]; + if ($creditsReady) { + $params[':credits'] = $credits !== '' ? $credits : null; + } + if ($socialReady) { + $params[':social_links'] = $socialJson; + } + $stmt->execute($params); + } else { + $stmt = $db->prepare(" + INSERT INTO ac_artists (name, slug, bio" . ($creditsReady ? ", credits" : "") . ", country, avatar_url" . ($socialReady ? ", social_links" : "") . ", is_active) + VALUES (:name, :slug, :bio" . ($creditsReady ? ", :credits" : "") . ", :country, :avatar_url" . ($socialReady ? ", :social_links" : "") . ", :is_active) + "); + $params = [ + ':name' => $name, + ':slug' => $slug, + ':bio' => $bio !== '' ? $bio : null, + ':country' => $country !== '' ? $country : null, + ':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null, + ':is_active' => $isActive, + ]; + if ($creditsReady) { + $params[':credits'] = $credits !== '' ? $credits : null; + } + if ($socialReady) { + $params[':social_links'] = $socialJson; + } + $stmt->execute($params); + } + } catch (Throwable $e) { + error_log('AC artists save error: ' . $e->getMessage()); + return $this->saveError($id, 'Unable to save artist. Check table columns and input.'); + } + + return new Response('', 302, ['Location' => '/admin/artists']); + } + + public function adminDelete(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $id = (int)($_POST['id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $id > 0) { + $stmt = $db->prepare("DELETE FROM ac_artists WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/artists']); + } + + public function adminUpload(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $file = $_FILES['artist_avatar'] ?? null; + $artistId = (int)($_POST['artist_id'] ?? 0); + if (!$file || !isset($file['tmp_name'])) { + return $this->uploadRedirect($artistId); + } + if ($file['error'] !== UPLOAD_ERR_OK) { + return $this->uploadRedirect($artistId, $this->uploadErrorMessage((int)$file['error'])); + } + + $tmp = (string)$file['tmp_name']; + if ($tmp === '' || !is_uploaded_file($tmp)) { + return $this->uploadRedirect($artistId); + } + + $info = getimagesize($tmp); + if ($info === false) { + return $this->uploadRedirect($artistId, 'Avatar must be an image.'); + } + $ext = image_type_to_extension($info[2], false); + $allowed = ['jpg', 'jpeg', 'png', 'webp']; + if (!in_array($ext, $allowed, true)) { + return $this->uploadRedirect($artistId, 'Avatar must be JPG, PNG, or WEBP.'); + } + + $uploadDir = __DIR__ . '/../../uploads/media'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { + return $this->uploadRedirect($artistId, 'Upload directory is not writable.'); + } + if (!is_writable($uploadDir)) { + return $this->uploadRedirect($artistId, 'Upload directory is not writable.'); + } + + $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'avatar'; + $baseName = trim($baseName, '-'); + $fileName = ($baseName !== '' ? $baseName : 'avatar') . '-' . date('YmdHis') . '.' . $ext; + $dest = $uploadDir . '/' . $fileName; + if (!move_uploaded_file($tmp, $dest)) { + return $this->uploadRedirect($artistId, 'Upload failed.'); + } + + $fileUrl = '/uploads/media/' . $fileName; + $fileType = (string)($file['type'] ?? ''); + $fileSize = (int)($file['size'] ?? 0); + + $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'], + ':url' => $fileUrl, + ':type' => $fileType, + ':size' => $fileSize, + ]); + } catch (Throwable $e) { + } + } + + if ($artistId > 0 && $db instanceof PDO) { + $stmt = $db->prepare("UPDATE ac_artists SET avatar_url = :url WHERE id = :id"); + $stmt->execute([':url' => $fileUrl, ':id' => $artistId]); + } + + return $this->uploadRedirect($artistId, '', $fileUrl); + } + + public function adminInstall(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $db = Database::get(); + if ($db instanceof PDO) { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_artists ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL UNIQUE, + bio MEDIUMTEXT NULL, + credits MEDIUMTEXT NULL, + country VARCHAR(120) NULL, + avatar_url VARCHAR(255) NULL, + social_links TEXT NULL, + 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("ALTER TABLE ac_artists ADD COLUMN credits MEDIUMTEXT NULL"); + $db->exec("ALTER TABLE ac_artists ADD COLUMN social_links TEXT NULL"); + } catch (Throwable $e) { + } + } + + return new Response('', 302, ['Location' => '/admin/artists']); + } + + private function artistsTableReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $stmt = $db->query("SELECT 1 FROM ac_artists LIMIT 1"); + return $stmt !== false; + } catch (Throwable $e) { + return false; + } + } + + private function socialColumnReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'social_links'"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + return (bool)$row; + } catch (Throwable $e) { + return false; + } + } + + private function creditsColumnReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'credits'"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + return (bool)$row; + } catch (Throwable $e) { + return false; + } + } + + private function saveError(int $id, string $message): Response + { + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Artist' : 'New Artist', + 'artist' => [ + 'id' => $id, + 'name' => (string)($_POST['name'] ?? ''), + 'slug' => (string)($_POST['slug'] ?? ''), + 'bio' => (string)($_POST['bio'] ?? ''), + 'credits' => (string)($_POST['credits'] ?? ''), + 'country' => (string)($_POST['country'] ?? ''), + 'avatar_url' => (string)($_POST['avatar_url'] ?? ''), + 'social_links' => '', + 'is_active' => isset($_POST['is_active']) ? 1 : 0, + ], + 'error' => $message, + ])); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'artist'; + } + + private function uploadRedirect(int $artistId, string $error = '', string $url = ''): Response + { + if ($artistId > 0) { + $target = '/admin/artists/edit?id=' . $artistId; + } else { + $target = '/admin/artists/new'; + } + if ($url !== '') { + $target .= '&avatar_url=' . rawurlencode($url); + } + if ($error !== '') { + $target .= '&upload_error=' . rawurlencode($error); + } + return new Response('', 302, ['Location' => $target]); + } + + private function uploadErrorMessage(int $code): string + { + $max = (string)ini_get('upload_max_filesize'); + $map = [ + UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).", + UPLOAD_ERR_FORM_SIZE => 'File exceeds form 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 a PHP extension.', + UPLOAD_ERR_NO_FILE => 'No file uploaded.', + ]; + return $map[$code] ?? 'Upload failed.'; + } + + private function normalizeUrl(string $value): string + { + if ($value === '') { + return ''; + } + if (preg_match('~^(https?://)~i', $value)) { + return $value; + } + return 'https://' . $value; + } +} diff --git a/plugins/artists/plugin.json b/plugins/artists/plugin.json new file mode 100644 index 0000000..e0cd1ca --- /dev/null +++ b/plugins/artists/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "Artists", + "version": "0.1.0", + "description": "Public artist listings and profiles.", + "author": "AudioCore", + "admin_nav": { + "label": "Artists", + "url": "/admin/artists", + "roles": ["admin", "manager"], + "icon": "fa-solid fa-user" + }, + "pages": [ + { + "slug": "artists", + "title": "Artists", + "content_html": "
Artists

Artists

Add your artist roster here.

" + } + ], + "entry": "plugin.php", + "default_enabled": false +} diff --git a/plugins/artists/plugin.php b/plugins/artists/plugin.php new file mode 100644 index 0000000..be51e81 --- /dev/null +++ b/plugins/artists/plugin.php @@ -0,0 +1,23 @@ +get('/artists', [$controller, 'index']); + $router->get('/artist', [$controller, 'show']); + $router->get('/admin/artists', [$controller, 'adminIndex']); + $router->post('/admin/artists/install', [$controller, 'adminInstall']); + $router->get('/admin/artists/new', [$controller, 'adminNew']); + $router->get('/admin/artists/edit', function () use ($controller): Core\Http\Response { + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + return $controller->adminEdit($id); + }); + $router->post('/admin/artists/upload', [$controller, 'adminUpload']); + $router->post('/admin/artists/save', [$controller, 'adminSave']); + $router->post('/admin/artists/delete', [$controller, 'adminDelete']); +}; diff --git a/plugins/artists/views/admin/edit.php b/plugins/artists/views/admin/edit.php new file mode 100644 index 0000000..a18e63c --- /dev/null +++ b/plugins/artists/views/admin/edit.php @@ -0,0 +1,129 @@ + +
+
Artists
+
+
+

+

Create or update an artist profile.

+
+ Back +
+ + +
+ + +
+ + +
+
+
Upload avatar
+ + + +
+ +
+
+
+ +
+ +
+
+ + + + + + +
+ + +
+ + + + + +
+
Social Links
+
+ + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+
Artists
+
+
+

Artists

+

Artists plugin admin placeholder.

+
+ New Artist +
+ + +
+
+
Database not initialized
+
Create the artists table before adding records.
+
+
+ +
+
+ + +
+
+
Social links not enabled
+
Click create tables to add the social links column.
+
+
+ +
+
+ +
+
+
Artists page
+
+ Slug: artists + 0): ?> + · Status: + + · Not created + +
+
+ 0): ?> + Edit Page Content + + Re-enable plugin to create + +
+ +
No artists yet.
+ +
+ +
+
+
+ + + + N/A + +
+
+
+
+
+
+
+ + Inactive + + Edit +
+ + +
+
+
+ +
+ + +
+ +
+
+
Artists
+
+ + +
+
+ + +
No artists published yet.
+ + + +
+ + + + ['label' => 'Website', 'icon' => 'fa-duotone fa-globe-pointer'], + 'instagram' => ['label' => 'Instagram', 'icon' => 'fa-brands fa-instagram'], + 'soundcloud' => ['label' => 'SoundCloud', 'icon' => 'fa-brands fa-soundcloud'], + 'spotify' => ['label' => 'Spotify', 'icon' => 'fa-brands fa-spotify'], + 'youtube' => ['label' => 'YouTube', 'icon' => 'fa-brands fa-youtube'], + 'tiktok' => ['label' => 'TikTok', 'icon' => 'fa-brands fa-tiktok'], + 'bandcamp' => ['label' => 'Bandcamp', 'icon' => 'fa-brands fa-bandcamp'], + 'beatport' => ['label' => 'Beatport', 'icon' => 'fa-solid fa-music'], + 'facebook' => ['label' => 'Facebook', 'icon' => 'fa-brands fa-facebook'], + 'x' => ['label' => 'X', 'icon' => 'fa-brands fa-x-twitter'], +]; +function ac_normalize_url(string $value): string { + if ($value === '') { + return ''; + } + if (preg_match('~^(https?://)~i', $value)) { + return $value; + } + return 'https://' . $value; +} +ob_start(); +?> +
+
Artist
+ +

Artist not found

+

This profile is unavailable.

+ +
+
+
+ + + + AC + +
+
+
Profile
+

+ + +
+ +
+ +
+
+ + +
+ +
+ + + +
+
Credits
+
+ +
+
+ + + +
+ $url): ?> + ucfirst($key), 'icon' => 'fa-solid fa-link']; + $label = $meta['label']; + $icon = $meta['icon']; + $normalized = ac_normalize_url((string)$url); + $safeUrl = htmlspecialchars($normalized, ENT_QUOTES, 'UTF-8'); + ?> + + + + + + + + +
+ + + +
+
+
Latest Releases
+ View all +
+ +
+ +
+ +
+ + +view = new View(__DIR__ . '/views'); + } + + public function index(): Response + { + $this->ensureReleaseArtistColumn(); + $db = Database::get(); + $page = null; + $releases = []; + $artistOptions = []; + $artistFilter = trim((string)($_GET['artist'] ?? '')); + $search = trim((string)($_GET['q'] ?? '')); + $sort = trim((string)($_GET['sort'] ?? 'newest')); + $currentPage = max(1, (int)($_GET['p'] ?? 1)); + $perPage = 20; + $totalReleases = 0; + $totalPages = 1; + $allowedSorts = [ + 'newest' => 'r.release_date DESC, r.created_at DESC', + 'oldest' => 'r.release_date ASC, r.created_at ASC', + 'title_asc' => 'r.title ASC', + 'title_desc' => 'r.title DESC', + ]; + if (!isset($allowedSorts[$sort])) { + $sort = 'newest'; + } + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = 'releases' AND is_published = 1 LIMIT 1"); + $stmt->execute(); + $page = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + $artistJoinReady = false; + try { + $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); + $artistJoinReady = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); + } catch (Throwable $e) { + $artistJoinReady = false; + } + + $params = []; + $where = ["r.is_published = 1"]; + if ($artistFilter !== '') { + if ($artistJoinReady) { + $where[] = "(r.artist_name = :artist OR a.name = :artist)"; + } else { + $where[] = "r.artist_name = :artist"; + } + $params[':artist'] = $artistFilter; + } + if ($search !== '') { + $where[] = "(r.title LIKE :search OR r.catalog_no LIKE :search OR r.slug LIKE :search OR r.artist_name LIKE :search" . ($artistJoinReady ? " OR a.name LIKE :search" : "") . ")"; + $params[':search'] = '%' . $search . '%'; + } + + if ($artistJoinReady) { + $countSql = " + SELECT COUNT(*) AS total_rows + FROM ac_releases r + LEFT JOIN ac_artists a ON a.id = r.artist_id + WHERE " . implode(' AND ', $where); + $countStmt = $db->prepare($countSql); + $countStmt->execute($params); + $totalReleases = (int)($countStmt->fetchColumn() ?: 0); + $totalPages = max(1, (int)ceil($totalReleases / $perPage)); + if ($currentPage > $totalPages) { + $currentPage = $totalPages; + } + $offset = ($currentPage - 1) * $perPage; + + $listSql = " + SELECT r.id, r.title, r.slug, r.release_date, r.cover_url, + COALESCE(r.artist_name, a.name) AS artist_name + FROM ac_releases r + LEFT JOIN ac_artists a ON a.id = r.artist_id + WHERE " . implode(' AND ', $where) . " + ORDER BY {$allowedSorts[$sort]} + LIMIT :limit OFFSET :offset + "; + $listStmt = $db->prepare($listSql); + foreach ($params as $k => $v) { + $listStmt->bindValue($k, $v); + } + $listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT); + $listStmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $listStmt->execute(); + + $artistStmt = $db->query(" + SELECT DISTINCT TRIM(COALESCE(NULLIF(r.artist_name, ''), a.name)) AS artist_name + FROM ac_releases r + LEFT JOIN ac_artists a ON a.id = r.artist_id + WHERE r.is_published = 1 + ORDER BY artist_name ASC + "); + } else { + $countSql = " + SELECT COUNT(*) AS total_rows + FROM ac_releases r + WHERE " . implode(' AND ', $where); + $countStmt = $db->prepare($countSql); + $countStmt->execute($params); + $totalReleases = (int)($countStmt->fetchColumn() ?: 0); + $totalPages = max(1, (int)ceil($totalReleases / $perPage)); + if ($currentPage > $totalPages) { + $currentPage = $totalPages; + } + $offset = ($currentPage - 1) * $perPage; + + $listSql = " + SELECT r.id, r.title, r.slug, r.release_date, r.cover_url, r.artist_name + FROM ac_releases r + WHERE " . implode(' AND ', $where) . " + ORDER BY {$allowedSorts[$sort]} + LIMIT :limit OFFSET :offset + "; + $listStmt = $db->prepare($listSql); + foreach ($params as $k => $v) { + $listStmt->bindValue($k, $v); + } + $listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT); + $listStmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $listStmt->execute(); + + $artistStmt = $db->query(" + SELECT DISTINCT TRIM(artist_name) AS artist_name + FROM ac_releases + WHERE is_published = 1 + ORDER BY artist_name ASC + "); + } + + $releases = $listStmt->fetchAll(PDO::FETCH_ASSOC); + $rawArtistRows = $artistStmt ? $artistStmt->fetchAll(PDO::FETCH_ASSOC) : []; + foreach ($rawArtistRows as $row) { + $name = trim((string)($row['artist_name'] ?? '')); + if ($name !== '') { + $artistOptions[] = $name; + } + } + $artistOptions = array_values(array_unique($artistOptions)); + } catch (Throwable $e) { + } + } + + return new Response($this->view->render('site/index.php', [ + 'title' => (string)($page['title'] ?? 'Releases'), + 'content_html' => (string)($page['content_html'] ?? ''), + 'releases' => $releases, + 'total_releases' => $totalReleases, + 'per_page' => $perPage, + 'current_page' => $currentPage, + 'total_pages' => $totalPages, + 'artist_filter' => $artistFilter, + 'artist_options' => $artistOptions, + 'search' => $search, + 'sort' => $sort, + ])); + } + + public function show(): Response + { + $this->ensureReleaseArtistColumn(); + $slug = trim((string)($_GET['slug'] ?? '')); + $release = null; + $tracks = []; + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + if ($slug !== '') { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("SELECT * FROM ac_releases WHERE slug = :slug AND is_published = 1 LIMIT 1"); + $stmt->execute([':slug' => $slug]); + $release = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + if ($release) { + if ($storePluginEnabled) { + try { + $bundleStmt = $db->prepare(" + SELECT is_enabled, bundle_price, currency, purchase_label + FROM ac_store_release_products + WHERE release_id = :release_id + LIMIT 1 + "); + $bundleStmt->execute([':release_id' => (int)$release['id']]); + $bundle = $bundleStmt->fetch(PDO::FETCH_ASSOC); + if ($bundle) { + $release['store_enabled'] = (int)($bundle['is_enabled'] ?? 0); + $release['bundle_price'] = (float)($bundle['bundle_price'] ?? 0); + $release['store_currency'] = (string)($bundle['currency'] ?? 'GBP'); + $release['purchase_label'] = (string)($bundle['purchase_label'] ?? ''); + } + } catch (Throwable $e) { + } + } + if ($storePluginEnabled) { + $trackStmt = $db->prepare(" + SELECT t.id, t.track_no, t.title, t.mix_name, t.duration, t.bpm, t.key_signature, t.sample_url, + COALESCE(sp.is_enabled, 0) AS store_enabled, + COALESCE(sp.track_price, 0.00) AS track_price, + COALESCE(sp.currency, 'GBP') AS store_currency + FROM ac_release_tracks t + LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id = :rid + ORDER BY t.track_no ASC, t.id ASC + "); + } else { + $trackStmt = $db->prepare(" + SELECT id, track_no, title, mix_name, duration, bpm, key_signature, sample_url + FROM ac_release_tracks + WHERE release_id = :rid + ORDER BY track_no ASC, id ASC + "); + } + $trackStmt->execute([':rid' => (int)$release['id']]); + $tracks = $trackStmt->fetchAll(PDO::FETCH_ASSOC); + } + } catch (Throwable $e) { + $tracks = []; + } + } + } + return new Response($this->view->render('site/show.php', [ + 'title' => $release ? (string)$release['title'] : 'Release', + 'release' => $release, + 'tracks' => $tracks, + 'store_plugin_enabled' => $storePluginEnabled, + ])); + } + + public function adminIndex(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $this->ensureReleaseArtistColumn(); + $tableReady = $this->releasesTableReady(); + $releases = []; + $pageId = 0; + $pagePublished = 0; + if ($tableReady) { + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->query("SELECT id, title, slug, artist_name, release_date, cover_url, is_published FROM ac_releases ORDER BY created_at DESC"); + $releases = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + $pageStmt = $db->prepare("SELECT id, is_published FROM ac_pages WHERE slug = 'releases' LIMIT 1"); + $pageStmt->execute(); + $pageRow = $pageStmt->fetch(PDO::FETCH_ASSOC); + if ($pageRow) { + $pageId = (int)($pageRow['id'] ?? 0); + $pagePublished = (int)($pageRow['is_published'] ?? 0); + } + } + } + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Releases', + 'table_ready' => $tableReady, + 'releases' => $releases, + 'page_id' => $pageId, + 'page_published' => $pagePublished, + ])); + } + + public function adminNew(): Response + { + return $this->adminEdit(0); + } + + public function adminEdit(int $id = 0): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + + $release = [ + 'id' => 0, + 'title' => '', + 'slug' => '', + 'artist_name' => '', + 'description' => '', + 'credits' => '', + 'catalog_no' => '', + 'release_date' => '', + 'cover_url' => '', + 'sample_url' => '', + 'is_published' => 1, + 'store_enabled' => 0, + 'bundle_price' => '', + 'store_currency' => 'GBP', + 'purchase_label' => '', + ]; + if ($id > 0) { + $db = Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_releases WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $release = array_merge($release, $row); + } + if ($storePluginEnabled) { + try { + $storeStmt = $db->prepare(" + SELECT is_enabled, bundle_price, currency, purchase_label + FROM ac_store_release_products + WHERE release_id = :release_id + LIMIT 1 + "); + $storeStmt->execute([':release_id' => $id]); + $storeRow = $storeStmt->fetch(PDO::FETCH_ASSOC); + if ($storeRow) { + $release['store_enabled'] = (int)($storeRow['is_enabled'] ?? 0); + $release['bundle_price'] = (string)($storeRow['bundle_price'] ?? ''); + $release['store_currency'] = (string)($storeRow['currency'] ?? 'GBP'); + $release['purchase_label'] = (string)($storeRow['purchase_label'] ?? ''); + } + } catch (Throwable $e) { + } + } + } + } + if (!empty($_GET['cover_url']) && $release['cover_url'] === '') { + $release['cover_url'] = (string)$_GET['cover_url']; + } + if (!empty($_GET['sample_url']) && $release['sample_url'] === '') { + $release['sample_url'] = (string)$_GET['sample_url']; + } + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Release' : 'New Release', + 'release' => $release, + 'store_plugin_enabled' => $storePluginEnabled, + 'error' => '', + ])); + } + + public function adminTracks(int $releaseId = 0): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $release = null; + $tracks = []; + $tableReady = $this->tracksTableReady(); + + if ($tableReady && $releaseId > 0) { + $db = Database::get(); + if ($db instanceof PDO) { + $relStmt = $db->prepare("SELECT id, title, slug FROM ac_releases WHERE id = :id LIMIT 1"); + $relStmt->execute([':id' => $releaseId]); + $release = $relStmt->fetch(PDO::FETCH_ASSOC) ?: null; + if ($release) { + $trackStmt = $db->prepare(" + SELECT id, track_no, title, mix_name, duration, bpm, key_signature, sample_url + FROM ac_release_tracks + WHERE release_id = :rid + ORDER BY track_no ASC, id ASC + "); + $trackStmt->execute([':rid' => $releaseId]); + $tracks = $trackStmt->fetchAll(PDO::FETCH_ASSOC); + } + } + } + + return new Response($this->view->render('admin/tracks_index.php', [ + 'title' => 'Release Tracks', + 'release' => $release, + 'tracks' => $tracks, + 'table_ready' => $tableReady, + 'release_id' => $releaseId, + ])); + } + + public function adminTrackEdit(int $id = 0, int $releaseId = 0): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + + $track = [ + 'id' => 0, + 'release_id' => $releaseId, + 'track_no' => '', + 'title' => '', + 'mix_name' => '', + 'duration' => '', + 'bpm' => '', + 'key_signature' => '', + 'sample_url' => '', + 'store_enabled' => 0, + 'track_price' => '', + 'store_currency' => 'GBP', + 'full_file_url' => '', + ]; + + $release = null; + $db = Database::get(); + if ($db instanceof PDO && $releaseId > 0) { + $relStmt = $db->prepare("SELECT id, title FROM ac_releases WHERE id = :id LIMIT 1"); + $relStmt->execute([':id' => $releaseId]); + $release = $relStmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + if ($id > 0 && $db instanceof PDO) { + $stmt = $db->prepare("SELECT * FROM ac_release_tracks WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $track = array_merge($track, $row); + $releaseId = (int)($track['release_id'] ?? $releaseId); + if ($storePluginEnabled) { + try { + $storeStmt = $db->prepare(" + SELECT is_enabled, track_price, currency + FROM ac_store_track_products + WHERE release_track_id = :track_id + LIMIT 1 + "); + $storeStmt->execute([':track_id' => $id]); + $storeRow = $storeStmt->fetch(PDO::FETCH_ASSOC); + if ($storeRow) { + $track['store_enabled'] = (int)($storeRow['is_enabled'] ?? 0); + $track['track_price'] = (string)($storeRow['track_price'] ?? ''); + $track['store_currency'] = (string)($storeRow['currency'] ?? 'GBP'); + } + } catch (Throwable $e) { + } + try { + $fileStmt = $db->prepare(" + SELECT file_url + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id = :track_id AND is_active = 1 + ORDER BY id DESC + LIMIT 1 + "); + $fileStmt->execute([':track_id' => $id]); + $fileRow = $fileStmt->fetch(PDO::FETCH_ASSOC); + if ($fileRow) { + $track['full_file_url'] = (string)($fileRow['file_url'] ?? ''); + } + } catch (Throwable $e) { + } + } + } + } + + if (!empty($_GET['sample_url']) && $track['sample_url'] === '') { + $track['sample_url'] = (string)$_GET['sample_url']; + } + if (!empty($_GET['full_file_url']) && $track['full_file_url'] === '') { + $track['full_file_url'] = (string)$_GET['full_file_url']; + } + + return new Response($this->view->render('admin/track_edit.php', [ + 'title' => $id > 0 ? 'Edit Track' : 'New Track', + 'track' => $track, + 'release' => $release, + 'store_plugin_enabled' => $storePluginEnabled, + 'error' => '', + ])); + } + + public function adminTrackSave(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + + $id = (int)($_POST['id'] ?? 0); + $releaseId = (int)($_POST['release_id'] ?? 0); + $trackNo = (int)($_POST['track_no'] ?? 0); + $title = trim((string)($_POST['title'] ?? '')); + $mixName = trim((string)($_POST['mix_name'] ?? '')); + $duration = trim((string)($_POST['duration'] ?? '')); + $bpm = trim((string)($_POST['bpm'] ?? '')); + $keySig = trim((string)($_POST['key_signature'] ?? '')); + $sampleUrl = trim((string)($_POST['sample_url'] ?? '')); + $storeEnabled = isset($_POST['store_enabled']) ? 1 : 0; + $trackPrice = trim((string)($_POST['track_price'] ?? '')); + $storeCurrency = strtoupper(trim((string)($_POST['store_currency'] ?? 'GBP'))); + $fullFileUrl = trim((string)($_POST['full_file_url'] ?? '')); + if (!preg_match('/^[A-Z]{3}$/', $storeCurrency)) { + $storeCurrency = 'GBP'; + } + + if ($releaseId <= 0) { + return $this->trackSaveError($id, $releaseId, 'Release is required.'); + } + if ($title === '') { + return $this->trackSaveError($id, $releaseId, 'Track title is required.'); + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return $this->trackSaveError($id, $releaseId, 'Database unavailable.'); + } + + try { + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_release_tracks + SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration, + bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url + WHERE id = :id + "); + $stmt->execute([ + ':track_no' => $trackNo > 0 ? $trackNo : null, + ':title' => $title, + ':mix_name' => $mixName !== '' ? $mixName : null, + ':duration' => $duration !== '' ? $duration : null, + ':bpm' => $bpm !== '' ? (int)$bpm : null, + ':key_signature' => $keySig !== '' ? $keySig : null, + ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ':id' => $id, + ]); + $trackId = $id; + } else { + $stmt = $db->prepare(" + INSERT INTO ac_release_tracks + (release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url) + VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url) + "); + $stmt->execute([ + ':release_id' => $releaseId, + ':track_no' => $trackNo > 0 ? $trackNo : null, + ':title' => $title, + ':mix_name' => $mixName !== '' ? $mixName : null, + ':duration' => $duration !== '' ? $duration : null, + ':bpm' => $bpm !== '' ? (int)$bpm : null, + ':key_signature' => $keySig !== '' ? $keySig : null, + ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ]); + $trackId = (int)$db->lastInsertId(); + } + + if ($storePluginEnabled) { + try { + $storeStmt = $db->prepare(" + INSERT INTO ac_store_track_products (release_track_id, is_enabled, track_price, currency, created_at, updated_at) + VALUES (:track_id, :is_enabled, :track_price, :currency, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + is_enabled = VALUES(is_enabled), + track_price = VALUES(track_price), + currency = VALUES(currency), + updated_at = NOW() + "); + $storeStmt->execute([ + ':track_id' => $trackId, + ':is_enabled' => $storeEnabled, + ':track_price' => $trackPrice !== '' ? (float)$trackPrice : 0.00, + ':currency' => $storeCurrency, + ]); + } catch (Throwable $e) { + } + + if ($fullFileUrl !== '') { + try { + $db->prepare(" + UPDATE ac_store_files + SET is_active = 0 + WHERE scope_type = 'track' AND scope_id = :track_id + ")->execute([':track_id' => $trackId]); + + $fileName = basename(parse_url($fullFileUrl, PHP_URL_PATH) ?: $fullFileUrl); + $insFile = $db->prepare(" + INSERT INTO ac_store_files + (scope_type, scope_id, file_url, file_name, file_size, mime_type, is_active, created_at) + VALUES ('track', :scope_id, :file_url, :file_name, NULL, NULL, 1, NOW()) + "); + $insFile->execute([ + ':scope_id' => $trackId, + ':file_url' => $fullFileUrl, + ':file_name' => $fileName !== '' ? $fileName : 'track-file', + ]); + } catch (Throwable $e) { + } + } + } + } catch (Throwable $e) { + error_log('AC release tracks save error: ' . $e->getMessage()); + return $this->trackSaveError($id, $releaseId, 'Unable to save track.'); + } + + return new Response('', 302, ['Location' => '/admin/releases/tracks?release_id=' . $releaseId]); + } + + public function adminTrackDelete(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $id = (int)($_POST['id'] ?? 0); + $releaseId = (int)($_POST['release_id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $id > 0) { + $stmt = $db->prepare("DELETE FROM ac_release_tracks WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/releases/tracks?release_id=' . $releaseId]); + } + + public function adminTrackUpload(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + $file = $_FILES['track_sample'] ?? null; + $trackId = (int)($_POST['track_id'] ?? ($_POST['id'] ?? 0)); + $releaseId = (int)($_POST['release_id'] ?? 0); + $uploadKind = (string)($_POST['upload_kind'] ?? 'sample'); + if ($uploadKind === 'full' && !$storePluginEnabled) { + return $this->trackUploadRedirect($releaseId, $trackId, 'Enable Store plugin to upload full track files.'); + } + if (!$file || !isset($file['tmp_name'])) { + return $this->trackUploadRedirect($releaseId, $trackId); + } + if ($file['error'] !== UPLOAD_ERR_OK) { + return $this->trackUploadRedirect($releaseId, $trackId, $this->uploadErrorMessage((int)$file['error'])); + } + $tmp = (string)$file['tmp_name']; + if ($tmp === '' || !is_uploaded_file($tmp)) { + return $this->trackUploadRedirect($releaseId, $trackId); + } + + $uploadDir = __DIR__ . '/../../uploads/media'; + $trackFolder = 'tracks'; + if ($uploadKind === 'full') { + $privateRoot = rtrim(Settings::get('store_private_root', '/home/audiocore.site/private_downloads'), '/'); + if ($privateRoot === '') { + return $this->trackUploadRedirect($releaseId, $trackId, 'Private download root is not configured.'); + } + $trackName = 'track-' . ($trackId > 0 ? (string)$trackId : date('YmdHis')); + $db = Database::get(); + if ($db instanceof PDO && $trackId > 0) { + try { + $trackStmt = $db->prepare("SELECT title FROM ac_release_tracks WHERE id = :id LIMIT 1"); + $trackStmt->execute([':id' => $trackId]); + $trackRow = $trackStmt->fetch(PDO::FETCH_ASSOC); + $candidate = trim((string)($trackRow['title'] ?? '')); + if ($candidate !== '') { + $trackName = $candidate; + } + } catch (Throwable $e) { + } + } + $trackSlug = $this->slugify($trackName); + $trackFolder = 'tracks/' . $trackSlug; + $uploadDir = $privateRoot . '/' . $trackFolder; + } + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { + return $this->trackUploadRedirect($releaseId, $trackId, 'Upload directory is not writable.'); + } + if (!is_writable($uploadDir)) { + return $this->trackUploadRedirect($releaseId, $trackId, 'Upload directory is not writable.'); + } + + $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); + if ($ext !== 'mp3') { + $msg = $uploadKind === 'full' ? 'Full track must be an MP3.' : 'Sample must be an MP3.'; + return $this->trackUploadRedirect($releaseId, $trackId, $msg); + } + + $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'sample'; + $baseName = trim($baseName, '-'); + $fileName = ($baseName !== '' ? $baseName : 'sample') . '-' . date('YmdHis') . '.' . $ext; + $dest = $uploadDir . '/' . $fileName; + if (!move_uploaded_file($tmp, $dest)) { + return $this->trackUploadRedirect($releaseId, $trackId, 'Upload failed.'); + } + + $fileUrl = '/uploads/media/' . $fileName; + if ($uploadKind === 'full') { + $fileUrl = $trackFolder . '/' . $fileName; + } + $fileType = (string)($file['type'] ?? ''); + $fileSize = (int)($file['size'] ?? 0); + + $db = Database::get(); + if ($db instanceof PDO && $uploadKind !== 'full') { + 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'], + ':url' => $fileUrl, + ':type' => $fileType, + ':size' => $fileSize, + ]); + } catch (Throwable $e) { + } + } + + if ($trackId > 0 && $db instanceof PDO) { + if ($uploadKind === 'full') { + try { + $db->prepare(" + UPDATE ac_store_files + SET is_active = 0 + WHERE scope_type = 'track' AND scope_id = :track_id + ")->execute([':track_id' => $trackId]); + + $insFile = $db->prepare(" + INSERT INTO ac_store_files + (scope_type, scope_id, file_url, file_name, file_size, mime_type, is_active, created_at) + VALUES ('track', :scope_id, :file_url, :file_name, :file_size, :mime_type, 1, NOW()) + "); + $insFile->execute([ + ':scope_id' => $trackId, + ':file_url' => $fileUrl, + ':file_name' => (string)$file['name'], + ':file_size' => $fileSize > 0 ? $fileSize : null, + ':mime_type' => $fileType !== '' ? $fileType : null, + ]); + } catch (Throwable $e) { + } + } else { + $stmt = $db->prepare("UPDATE ac_release_tracks SET sample_url = :url WHERE id = :id"); + $stmt->execute([':url' => $fileUrl, ':id' => $trackId]); + } + } + + return $this->trackUploadRedirect($releaseId, $trackId, '', $fileUrl, $uploadKind); + } + + public function adminSave(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + $id = (int)($_POST['id'] ?? 0); + $this->ensureReleaseArtistColumn(); + $title = trim((string)($_POST['title'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + $artistName = trim((string)($_POST['artist_name'] ?? '')); + $description = trim((string)($_POST['description'] ?? '')); + $credits = trim((string)($_POST['credits'] ?? '')); + $catalogNo = trim((string)($_POST['catalog_no'] ?? '')); + $releaseDate = trim((string)($_POST['release_date'] ?? '')); + $coverUrl = trim((string)($_POST['cover_url'] ?? '')); + $sampleUrl = trim((string)($_POST['sample_url'] ?? '')); + $isPublished = isset($_POST['is_published']) ? 1 : 0; + $storeEnabled = isset($_POST['store_enabled']) ? 1 : 0; + $bundlePrice = trim((string)($_POST['bundle_price'] ?? '')); + $storeCurrency = strtoupper(trim((string)($_POST['store_currency'] ?? 'GBP'))); + $purchaseLabel = trim((string)($_POST['purchase_label'] ?? '')); + if (!preg_match('/^[A-Z]{3}$/', $storeCurrency)) { + $storeCurrency = 'GBP'; + } + + if ($title === '') { + return $this->saveError($id, 'Title is required.'); + } + $slug = $slug !== '' ? $this->slugify($slug) : $this->slugify($title); + + $db = Database::get(); + if (!$db instanceof PDO) { + return $this->saveError($id, 'Database unavailable.'); + } + + $dupStmt = $id > 0 + ? $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug AND id != :id LIMIT 1") + : $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug LIMIT 1"); + $params = $id > 0 ? [':slug' => $slug, ':id' => $id] : [':slug' => $slug]; + $dupStmt->execute($params); + if ($dupStmt->fetch()) { + return $this->saveError($id, 'Slug already exists.'); + } + + try { + if ($id > 0) { + $stmt = $db->prepare(" + UPDATE ac_releases + SET title = :title, slug = :slug, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date, + cover_url = :cover_url, sample_url = :sample_url, is_published = :is_published + WHERE id = :id + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':artist_name' => $artistName !== '' ? $artistName : null, + ':description' => $description !== '' ? $description : null, + ':credits' => $credits !== '' ? $credits : null, + ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, + ':release_date' => $releaseDate !== '' ? $releaseDate : null, + ':cover_url' => $coverUrl !== '' ? $coverUrl : null, + ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ':is_published' => $isPublished, + ':id' => $id, + ]); + $releaseId = $id; + } else { + $stmt = $db->prepare(" + INSERT INTO ac_releases (title, slug, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) + VALUES (:title, :slug, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':artist_name' => $artistName !== '' ? $artistName : null, + ':description' => $description !== '' ? $description : null, + ':credits' => $credits !== '' ? $credits : null, + ':catalog_no' => $catalogNo !== '' ? $catalogNo : null, + ':release_date' => $releaseDate !== '' ? $releaseDate : null, + ':cover_url' => $coverUrl !== '' ? $coverUrl : null, + ':sample_url' => $sampleUrl !== '' ? $sampleUrl : null, + ':is_published' => $isPublished, + ]); + $releaseId = (int)$db->lastInsertId(); + } + + if ($storePluginEnabled) { + try { + $storeStmt = $db->prepare(" + INSERT INTO ac_store_release_products (release_id, is_enabled, bundle_price, currency, purchase_label, created_at, updated_at) + VALUES (:release_id, :is_enabled, :bundle_price, :currency, :purchase_label, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + is_enabled = VALUES(is_enabled), + bundle_price = VALUES(bundle_price), + currency = VALUES(currency), + purchase_label = VALUES(purchase_label), + updated_at = NOW() + "); + $storeStmt->execute([ + ':release_id' => $releaseId, + ':is_enabled' => $storeEnabled, + ':bundle_price' => $bundlePrice !== '' ? (float)$bundlePrice : 0.00, + ':currency' => $storeCurrency, + ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null, + ]); + } catch (Throwable $e) { + } + } + } catch (Throwable $e) { + error_log('AC releases save error: ' . $e->getMessage()); + return $this->saveError($id, 'Unable to save release. Check table columns and input.'); + } + + return new Response('', 302, ['Location' => '/admin/releases']); + } + + public function adminDelete(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $id = (int)($_POST['id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $id > 0) { + $stmt = $db->prepare("DELETE FROM ac_releases WHERE id = :id"); + $stmt->execute([':id' => $id]); + } + return new Response('', 302, ['Location' => '/admin/releases']); + } + + public function adminUpload(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $type = (string)($_POST['upload_type'] ?? 'cover'); + $file = $type === 'sample' ? ($_FILES['release_sample'] ?? null) : ($_FILES['release_cover'] ?? null); + $releaseId = (int)($_POST['release_id'] ?? 0); + if (!$file || !isset($file['tmp_name'])) { + return $this->uploadRedirect($releaseId); + } + if ($file['error'] !== UPLOAD_ERR_OK) { + return $this->uploadRedirect($releaseId, $this->uploadErrorMessage((int)$file['error'])); + } + + $tmp = (string)$file['tmp_name']; + if ($tmp === '' || !is_uploaded_file($tmp)) { + return $this->uploadRedirect($releaseId); + } + + $uploadDir = __DIR__ . '/../../uploads/media'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { + return $this->uploadRedirect($releaseId, 'Upload directory is not writable.'); + } + if (!is_writable($uploadDir)) { + return $this->uploadRedirect($releaseId, 'Upload directory is not writable.'); + } + + if ($type === 'sample') { + $ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)); + if ($ext !== 'mp3') { + return $this->uploadRedirect($releaseId, 'Sample must be an MP3.'); + } + } else { + $info = getimagesize($tmp); + if ($info === false) { + return $this->uploadRedirect($releaseId, 'Cover must be an image.'); + } + $ext = image_type_to_extension($info[2], false); + $allowed = ['jpg', 'jpeg', 'png', 'webp']; + if (!in_array($ext, $allowed, true)) { + return $this->uploadRedirect($releaseId, 'Cover must be JPG, PNG, or WEBP.'); + } + } + + $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'file'; + $baseName = trim($baseName, '-'); + $fileName = ($baseName !== '' ? $baseName : 'file') . '-' . date('YmdHis') . '.' . $ext; + $dest = $uploadDir . '/' . $fileName; + if (!move_uploaded_file($tmp, $dest)) { + return $this->uploadRedirect($releaseId, 'Upload failed.'); + } + + $fileUrl = '/uploads/media/' . $fileName; + $fileType = (string)($file['type'] ?? ''); + $fileSize = (int)($file['size'] ?? 0); + + $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'], + ':url' => $fileUrl, + ':type' => $fileType, + ':size' => $fileSize, + ]); + } catch (Throwable $e) { + } + } + + if ($releaseId > 0 && $db instanceof PDO) { + if ($type === 'sample') { + $stmt = $db->prepare("UPDATE ac_releases SET sample_url = :url WHERE id = :id"); + } else { + $stmt = $db->prepare("UPDATE ac_releases SET cover_url = :url WHERE id = :id"); + } + $stmt->execute([':url' => $fileUrl, ':id' => $releaseId]); + } + + return $this->uploadRedirect($releaseId, '', $fileUrl, $type); + } + + public function adminInstall(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $db = Database::get(); + if ($db instanceof PDO) { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_releases ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL UNIQUE, + artist_name VARCHAR(200) NULL, + description MEDIUMTEXT NULL, + credits MEDIUMTEXT NULL, + catalog_no VARCHAR(120) NULL, + release_date DATE NULL, + cover_url VARCHAR(255) NULL, + sample_url VARCHAR(255) NULL, + is_published 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("ALTER TABLE ac_releases ADD COLUMN sample_url VARCHAR(255) NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN cover_url VARCHAR(255) NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN release_date DATE NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN description MEDIUMTEXT NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN credits MEDIUMTEXT NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN catalog_no VARCHAR(120) NULL"); + $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_name VARCHAR(200) NULL"); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_release_tracks ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_id INT UNSIGNED NOT NULL, + track_no INT NULL, + title VARCHAR(200) NOT NULL, + mix_name VARCHAR(200) NULL, + duration VARCHAR(20) NULL, + bpm INT NULL, + key_signature VARCHAR(50) NULL, + sample_url VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_release_tracks_release (release_id), + CONSTRAINT fk_release_tracks_release + FOREIGN KEY (release_id) REFERENCES ac_releases(id) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN track_no INT NULL"); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN mix_name VARCHAR(200) NULL"); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN duration VARCHAR(20) NULL"); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN bpm INT NULL"); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN key_signature VARCHAR(50) NULL"); + $db->exec("ALTER TABLE ac_release_tracks ADD COLUMN sample_url VARCHAR(255) NULL"); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_release_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + purchase_label VARCHAR(120) 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_store_track_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_track_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + 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_store_files ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + scope_type ENUM('release','track') NOT NULL, + scope_id INT UNSIGNED NOT NULL, + file_url VARCHAR(1024) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_size BIGINT UNSIGNED NULL, + mime_type VARCHAR(128) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + return new Response('', 302, ['Location' => '/admin/releases']); + } + + private function ensureReleaseArtistColumn(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_name'"); + $exists = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); + if (!$exists) { + $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_name VARCHAR(200) NULL AFTER slug"); + } + } catch (Throwable $e) { + } + } + + private function releasesTableReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $stmt = $db->query("SELECT 1 FROM ac_releases LIMIT 1"); + return $stmt !== false; + } catch (Throwable $e) { + return false; + } + } + + private function saveError(int $id, string $message): Response + { + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + return new Response($this->view->render('admin/edit.php', [ + 'title' => $id > 0 ? 'Edit Release' : 'New Release', + 'release' => [ + 'id' => $id, + 'title' => (string)($_POST['title'] ?? ''), + 'slug' => (string)($_POST['slug'] ?? ''), + 'artist_name' => (string)($_POST['artist_name'] ?? ''), + 'description' => (string)($_POST['description'] ?? ''), + 'credits' => (string)($_POST['credits'] ?? ''), + 'catalog_no' => (string)($_POST['catalog_no'] ?? ''), + 'release_date' => (string)($_POST['release_date'] ?? ''), + 'cover_url' => (string)($_POST['cover_url'] ?? ''), + 'sample_url' => (string)($_POST['sample_url'] ?? ''), + 'is_published' => isset($_POST['is_published']) ? 1 : 0, + 'store_enabled' => isset($_POST['store_enabled']) ? 1 : 0, + 'bundle_price' => (string)($_POST['bundle_price'] ?? ''), + 'store_currency' => (string)($_POST['store_currency'] ?? 'GBP'), + 'purchase_label' => (string)($_POST['purchase_label'] ?? ''), + ], + 'store_plugin_enabled' => $storePluginEnabled, + 'error' => $message, + ])); + } + + private function tracksTableReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $stmt = $db->query("SELECT 1 FROM ac_release_tracks LIMIT 1"); + return $stmt !== false; + } catch (Throwable $e) { + return false; + } + } + + private function trackSaveError(int $id, int $releaseId, string $message): Response + { + Plugins::sync(); + $storePluginEnabled = Plugins::isEnabled('store'); + return new Response($this->view->render('admin/track_edit.php', [ + 'title' => $id > 0 ? 'Edit Track' : 'New Track', + 'track' => [ + 'id' => $id, + 'release_id' => $releaseId, + 'track_no' => (string)($_POST['track_no'] ?? ''), + 'title' => (string)($_POST['title'] ?? ''), + 'mix_name' => (string)($_POST['mix_name'] ?? ''), + 'duration' => (string)($_POST['duration'] ?? ''), + 'bpm' => (string)($_POST['bpm'] ?? ''), + 'key_signature' => (string)($_POST['key_signature'] ?? ''), + 'sample_url' => (string)($_POST['sample_url'] ?? ''), + 'store_enabled' => isset($_POST['store_enabled']) ? 1 : 0, + 'track_price' => (string)($_POST['track_price'] ?? ''), + 'store_currency' => (string)($_POST['store_currency'] ?? 'GBP'), + 'full_file_url' => (string)($_POST['full_file_url'] ?? ''), + ], + 'release' => null, + 'store_plugin_enabled' => $storePluginEnabled, + 'error' => $message, + ])); + } + + private function trackUploadRedirect(int $releaseId, int $trackId, string $error = '', string $url = '', string $uploadKind = 'sample'): Response + { + $target = '/admin/releases/tracks/edit?release_id=' . $releaseId; + if ($trackId > 0) { + $target .= '&id=' . $trackId; + } + if ($url !== '') { + $param = $uploadKind === 'full' ? 'full_file_url' : 'sample_url'; + $target .= '&' . $param . '=' . rawurlencode($url); + } + if ($error !== '') { + $target .= '&upload_error=' . rawurlencode($error); + } + return new Response('', 302, ['Location' => $target]); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'release'; + } + + private function uploadRedirect(int $releaseId, string $error = '', string $url = '', string $type = ''): Response + { + $target = $releaseId > 0 + ? '/admin/releases/edit?id=' . $releaseId + : '/admin/releases/new'; + + $queryPrefix = strpos($target, '?') === false ? '?' : '&'; + if ($url !== '') { + $param = $type === 'sample' ? 'sample_url' : 'cover_url'; + $target .= $queryPrefix . $param . '=' . rawurlencode($url); + $queryPrefix = '&'; + } + if ($error !== '') { + $target .= $queryPrefix . 'upload_error=' . rawurlencode($error); + } + return new Response('', 302, ['Location' => $target]); + } + + private function uploadErrorMessage(int $code): string + { + $max = (string)ini_get('upload_max_filesize'); + $map = [ + UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).", + UPLOAD_ERR_FORM_SIZE => 'File exceeds form 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 a PHP extension.', + UPLOAD_ERR_NO_FILE => 'No file uploaded.', + ]; + return $map[$code] ?? 'Upload failed.'; + } +} diff --git a/plugins/releases/plugin.json b/plugins/releases/plugin.json new file mode 100644 index 0000000..c6a14bb --- /dev/null +++ b/plugins/releases/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "Releases", + "version": "0.1.0", + "description": "Release listings and detail pages.", + "author": "AudioCore", + "admin_nav": { + "label": "Releases", + "url": "/admin/releases", + "roles": ["admin", "manager"], + "icon": "fa-solid fa-compact-disc" + }, + "pages": [ + { + "slug": "releases", + "title": "Releases", + "content_html": "
Releases

Releases

Latest drops from the label.

" + } + ], + "entry": "plugin.php", + "default_enabled": false +} diff --git a/plugins/releases/plugin.php b/plugins/releases/plugin.php new file mode 100644 index 0000000..e6fb1b2 --- /dev/null +++ b/plugins/releases/plugin.php @@ -0,0 +1,109 @@ +query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); + $artistJoinReady = (bool)($probe && $probe->fetch(\PDO::FETCH_ASSOC)); + } catch (\Throwable $e) { + $artistJoinReady = false; + } + + if ($artistJoinReady) { + $stmt = $db->prepare(" + SELECT r.title, r.slug, r.release_date, r.cover_url, COALESCE(r.artist_name, a.name) AS artist_name + FROM ac_releases r + LEFT JOIN ac_artists a ON a.id = r.artist_id + WHERE r.is_published = 1 + ORDER BY r.release_date DESC, r.created_at DESC + LIMIT :limit + "); + } else { + $stmt = $db->prepare(" + SELECT title, slug, release_date, cover_url, artist_name + FROM ac_releases + WHERE is_published = 1 + ORDER BY release_date DESC, created_at DESC + LIMIT :limit + "); + } + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (\Throwable $e) { + return ''; + } + + if (!$rows) { + return '
No releases published yet.
'; + } + + $cards = ''; + foreach ($rows as $row) { + $title = htmlspecialchars((string)($row['title'] ?? ''), ENT_QUOTES, 'UTF-8'); + $slug = rawurlencode((string)($row['slug'] ?? '')); + $artist = htmlspecialchars(trim((string)($row['artist_name'] ?? '')), ENT_QUOTES, 'UTF-8'); + $date = htmlspecialchars((string)($row['release_date'] ?? ''), ENT_QUOTES, 'UTF-8'); + $cover = trim((string)($row['cover_url'] ?? '')); + $coverHtml = $cover !== '' + ? '' + : '
AC
'; + $cards .= '' + . '
' . $coverHtml . '
' + . '
' + . '
' . $title . '
' + . ($artist !== '' ? '
' . $artist . '
' : '') + . ($date !== '' ? '
' . $date . '
' : '') + . '
' + . '
'; + } + + return '
' . $cards . '
'; +}); + +return function (Router $router): void { + $controller = new ReleasesController(); + $router->get('/releases', [$controller, 'index']); + $router->get('/release', [$controller, 'show']); + $router->get('/admin/releases', [$controller, 'adminIndex']); + $router->post('/admin/releases/install', [$controller, 'adminInstall']); + $router->get('/admin/releases/new', [$controller, 'adminNew']); + $router->get('/admin/releases/edit', function () use ($controller): Core\Http\Response { + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + return $controller->adminEdit($id); + }); + $router->get('/admin/releases/tracks', function () use ($controller): Core\Http\Response { + $releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0; + return $controller->adminTracks($releaseId); + }); + $router->get('/admin/releases/tracks/new', function () use ($controller): Core\Http\Response { + $releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0; + return $controller->adminTrackEdit(0, $releaseId); + }); + $router->get('/admin/releases/tracks/edit', function () use ($controller): Core\Http\Response { + $id = isset($_GET['id']) ? (int)$_GET['id'] : 0; + $releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0; + return $controller->adminTrackEdit($id, $releaseId); + }); + $router->post('/admin/releases/save', [$controller, 'adminSave']); + $router->post('/admin/releases/delete', [$controller, 'adminDelete']); + $router->post('/admin/releases/upload', [$controller, 'adminUpload']); + $router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']); + $router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']); + $router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']); +}; diff --git a/plugins/releases/views/admin/edit.php b/plugins/releases/views/admin/edit.php new file mode 100644 index 0000000..aa042fc --- /dev/null +++ b/plugins/releases/views/admin/edit.php @@ -0,0 +1,168 @@ + +
+
Releases
+
+
+

+

Create or update a release.

+
+
+ 0): ?> + Manage Tracks + + Back +
+
+ + +
+ + +
+ + +
+ +
+
+ + + + + + + + + + +
+
Upload cover
+ + + +
+ +
+
+
+ + +
+ +
+
Upload sample (MP3)
+ + + +
+ +
+
+ + + + + + + +
+
Store Options
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ +
+
+
+ + +
+
Releases
+
+
+

Releases

+

Manage singles, EPs, and albums.

+
+ New Release +
+ + +
+
+
Database not initialized
+
Create the releases table before adding records.
+
+
+ +
+
+ +
+
+
Releases page
+
+ Slug: releases + 0): ?> + - Status: + + - Not created + +
+
+ 0): ?> + Edit Page Content + + Re-enable plugin to create + +
+ + +
No releases yet.
+ +
+ +
+
+
+ + + + N/A + +
+
+
+
+
+
+
+ + Draft + + Tracks + Edit +
+ + +
+
+
+ +
+ + +
+ +
+
Releases
+
+
+

+

+ +

+
+ Back +
+ + +
+ + +
+ + +
+ + + + +
+
+ + + + + + + + + + + + + +
+
Upload sample (MP3)
+ + +
+ +
+
+ + + + + +
+
Upload full track (MP3)
+ + +
+ +
+
+ + + + +
+
Store Options
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ +
+
+
+ + +
+
Releases
+
+
+

Tracks

+

+ +

+
+
+ Back + 0): ?> + New Track + +
+
+ + +
Tracks table is not available. Run Releases ? Create Tables.
+ +
Release not found.
+ +
No tracks yet.
+ +
+ +
+
+
+ 0 ? (int)$track['track_no'] : '�' ?> +
+
+
+ + + () + +
+
+ + + +
+
+
+
+ Edit +
+ + + +
+
+
+ +
+ +
+ 1) { + $params['p'] = $page; + } + $qs = http_build_query($params); + return '/releases' . ($qs !== '' ? ('?' . $qs) : ''); +}; + +ob_start(); +?> + +
+
+
+
Releases
+

Latest Drops

+

Singles, EPs, and albums from the AudioCore catalog.

+
+ +
+
+ + +
+ + + + Reset +
+ + +
+
Artist:
+
Search:
+
Sort:
+
+ + + +
No releases published yet.
+ + + + + 1): ?> + + +
+
+ + + + +
+
Release
+ +

Release not found

+

This release is unavailable.

+ +
+
+
+

+ +
+ By + + + +
+ +
+ +
+
Catalog
+
+
+ + +
+
Release Date
+
+
+ +
+ +
+ +
+ + 0): ?> +
+
+ + + + + + + + + +
+
+ +
+ +
+ + + +
AUDIOCORE
+ +
+
+ + +
+
+
Tracklist
+
+
Select a track to play sample
+
+
+ +
+ + +
+ +
+
+ + + () + +
+
+ 0 ? '#' . (int)$track['track_no'] : 'Track' ?> + + + +
+
+
+ + 0): ?> +
+ + + + + + + + + +
+ +
+ +
+ +
+
+ +
+
+ + + +
+
Credits
+
+ +
+
+ +
+ +
+ + + + + + +view = new View(__DIR__ . '/views'); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + + $tablesReady = $this->tablesReady(); + $stats = [ + 'total_orders' => 0, + 'paid_orders' => 0, + 'total_revenue' => 0.0, + 'total_customers' => 0, + ]; + $recentOrders = []; + $newCustomers = []; + + if ($tablesReady) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['total_orders'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['paid_orders'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query("SELECT COALESCE(SUM(total), 0) AS revenue FROM ac_store_orders WHERE status = 'paid'"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['total_revenue'] = (float)($row['revenue'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['total_customers'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query(" + SELECT order_no, email, status, currency, total, created_at + FROM ac_store_orders + ORDER BY created_at DESC + LIMIT 5 + "); + $recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $recentOrders = []; + } + + try { + $stmt = $db->query(" + SELECT name, email, is_active, created_at + FROM ac_store_customers + ORDER BY created_at DESC + LIMIT 5 + "); + $newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $newCustomers = []; + } + } + } + + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Store', + 'tables_ready' => $tablesReady, + 'private_root' => Settings::get('store_private_root', $this->privateRoot()), + 'private_root_ready' => $this->privateRootReady(), + 'stats' => $stats, + 'recent_orders' => $recentOrders, + 'new_customers' => $newCustomers, + 'currency' => Settings::get('store_currency', 'GBP'), + ])); + } + + public function adminSettings(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureDiscountSchema(); + $this->ensureSalesChartSchema(); + $settings = $this->settingsPayload(); + $gateways = []; + foreach (Gateways::all() as $gateway) { + $gateways[] = [ + 'key' => $gateway->key(), + 'label' => $gateway->label(), + 'enabled' => $gateway->isEnabled($settings), + ]; + } + + return new Response($this->view->render('admin/settings.php', [ + 'title' => 'Store Settings', + 'settings' => $settings, + 'gateways' => $gateways, + 'discounts' => $this->adminDiscountRows(), + 'private_root_ready' => $this->privateRootReady(), + 'tab' => (string)($_GET['tab'] ?? 'general'), + 'error' => (string)($_GET['error'] ?? ''), + 'saved' => (string)($_GET['saved'] ?? ''), + 'chart_rows' => $this->salesChartRows( + (string)($settings['store_sales_chart_default_scope'] ?? 'tracks'), + (string)($settings['store_sales_chart_default_window'] ?? 'latest'), + max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10'))) + ), + 'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(), + 'chart_cron_url' => $this->salesChartCronUrl(), + 'chart_cron_cmd' => $this->salesChartCronCommand(), + ])); + } + + public function adminSaveSettings(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $current = $this->settingsPayload(); + + $currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP'); + $currency = strtoupper(trim($currencyRaw)); + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + $privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot()); + $privateRoot = trim($privateRootRaw); + if ($privateRoot === '') { + $privateRoot = $this->privateRoot(); + } + $downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5'); + $downloadLimit = max(1, (int)$downloadLimitRaw); + $expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30'); + $expiryDays = max(1, (int)$expiryDaysRaw); + $orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD'); + $orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw); + $testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1'); + $stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0'); + $stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? ''))); + $stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? ''))); + $paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0'); + $paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? ''))); + $paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? ''))); + $emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? ''))); + $orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}'))); + $orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? ''))); + if ($orderEmailHtml === '') { + $orderEmailHtml = $this->defaultOrderEmailHtml(); + } + $salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks')))); + if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) { + $salesChartDefaultScope = 'tracks'; + } + $salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest')))); + if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) { + $salesChartDefaultWindow = 'latest'; + } + $salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10')))); + $latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24')))); + $refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180')))); + + Settings::set('store_currency', $currency); + Settings::set('store_private_root', $privateRoot); + Settings::set('store_download_limit', (string)$downloadLimit); + Settings::set('store_download_expiry_days', (string)$expiryDays); + Settings::set('store_order_prefix', $orderPrefix); + Settings::set('store_test_mode', $testMode); + Settings::set('store_stripe_enabled', $stripeEnabled); + Settings::set('store_stripe_public_key', $stripePublic); + Settings::set('store_stripe_secret_key', $stripeSecret); + Settings::set('store_paypal_enabled', $paypalEnabled); + Settings::set('store_paypal_client_id', $paypalClientId); + Settings::set('store_paypal_secret', $paypalSecret); + Settings::set('store_email_logo_url', $emailLogoUrl); + Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}'); + Settings::set('store_order_email_html', $orderEmailHtml); + Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope); + Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow); + Settings::set('store_sales_chart_limit', (string)$salesChartLimit); + Settings::set('store_sales_chart_latest_hours', (string)$latestHours); + Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes); + if (isset($_POST['store_sales_chart_regen_key'])) { + try { + Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); + } catch (Throwable $e) { + } + } + if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') { + try { + Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); + } catch (Throwable $e) { + } + } + $this->ensureSalesChartSchema(); + + if (!$this->ensurePrivateRoot($privateRoot)) { + return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']); + } + + $tab = trim((string)($_POST['tab'] ?? 'general')); + $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general'; + return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]); + } + + public function adminRebuildSalesChart(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureSalesChartSchema(); + $ok = $this->rebuildSalesChartCache(); + if (!$ok) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']); + } + + public function salesChartCron(): Response + { + $this->ensureSalesChartSchema(); + $expected = trim((string)Settings::get('store_sales_chart_cron_key', '')); + $provided = trim((string)($_GET['key'] ?? '')); + if ($expected === '' || !hash_equals($expected, $provided)) { + return new Response('Unauthorized', 401); + } + $ok = $this->rebuildSalesChartCache(); + if (!$ok) { + return new Response('Sales chart rebuild failed', 500); + } + return new Response('OK'); + } + + public function adminDiscountCreate(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureDiscountSchema(); + $code = strtoupper(trim((string)($_POST['code'] ?? ''))); + $type = trim((string)($_POST['discount_type'] ?? 'percent')); + $value = (float)($_POST['discount_value'] ?? 0); + $maxUses = (int)($_POST['max_uses'] ?? 0); + $expiresAt = trim((string)($_POST['expires_at'] ?? '')); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']); + } + if (!in_array($type, ['percent', 'fixed'], true)) { + $type = 'percent'; + } + if ($value <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']); + } + if ($type === 'percent' && $value > 100) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']); + } + if ($maxUses < 0) { + $maxUses = 0; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']); + } + try { + $stmt = $db->prepare(" + INSERT INTO ac_store_discount_codes + (code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at) + VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + discount_type = VALUES(discount_type), + discount_value = VALUES(discount_value), + max_uses = VALUES(max_uses), + expires_at = VALUES(expires_at), + is_active = VALUES(is_active), + updated_at = NOW() + "); + $stmt->execute([ + ':code' => $code, + ':discount_type' => $type, + ':discount_value' => $value, + ':max_uses' => $maxUses, + ':expires_at' => $expiresAt !== '' ? $expiresAt : null, + ':is_active' => $isActive, + ]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); + } + + public function adminDiscountDelete(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureDiscountSchema(); + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']); + } + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']); + } + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); + } + + public function adminSendTestEmail(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $to = trim((string)($_POST['test_email_to'] ?? '')); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']); + } + + $subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'))); + $htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()))); + if ($htmlTpl === '') { + $htmlTpl = $this->defaultOrderEmailHtml(); + } + + $mockItems = [ + ['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], + ['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], + ]; + $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); + $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); + $logoHtml = $logoUrl !== '' + ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' + : ''; + $map = [ + '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), + '{{order_no}}' => 'AC-TEST-' . date('YmdHis'), + '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), + '{{currency}}' => 'GBP', + '{{total}}' => '2.98', + '{{status}}' => 'paid', + '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), + '{{logo_html}}' => $logoHtml, + '{{items_html}}' => $this->renderItemsHtml($mockItems, 'GBP'), + '{{download_links_html}}' => '

Example download links appear here after payment.

', + ]; + $subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map); + $html = strtr($htmlTpl, $map); + $mailSettings = [ + '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', 'AudioCore'), + ]; + $result = Mailer::send($to, $subject, $html, $mailSettings); + $ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999); + $this->logMailDebug($ref, [ + 'to' => $to, + 'subject' => $subject, + 'result' => $result, + ]); + if (!($result['ok'] ?? false)) { + $msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown')); + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']); + } + + public function adminTestPaypal(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $settings = $this->settingsPayload(); + $clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? ''))); + $secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? ''))); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']); + } + + $probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live'))); + $isSandbox = ($probeMode === 'sandbox'); + $result = $this->paypalTokenProbe($clientId, $secret, $isSandbox); + if (!($result['ok'] ?? false)) { + $err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed')); + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]); + } + + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]); + } + + public function adminCustomers(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + $q = trim((string)($_GET['q'] ?? '')); + $like = '%' . $q . '%'; + + $rows = []; + $db = Database::get(); + if ($db instanceof PDO) { + try { + $sql = " + SELECT + c.id, + c.name, + c.email, + c.is_active, + c.created_at, + COALESCE(os.order_count, 0) AS order_count, + COALESCE(os.revenue, 0) AS revenue, + os.last_order_no, + os.last_order_id, + os.last_ip, + os.last_order_at + FROM ac_store_customers c + LEFT JOIN ( + SELECT + o.email, + COUNT(*) AS order_count, + SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, + MAX(o.created_at) AS last_order_at, + SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no, + SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip + FROM ac_store_orders o + GROUP BY o.email + ) os ON os.email = c.email + "; + if ($q !== '') { + $sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q "; + } + $sql .= " + ORDER BY COALESCE(os.last_order_at, c.created_at) DESC + LIMIT 500 + "; + $stmt = $db->prepare($sql); + if ($q !== '') { + $stmt->execute([':q' => $like]); + } else { + $stmt->execute(); + } + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $rows = []; + } + + try { + $guestSql = " + SELECT + NULL AS id, + '' AS name, + o.email AS email, + 1 AS is_active, + MIN(o.created_at) AS created_at, + COUNT(*) AS order_count, + SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, + SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no, + SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip, + MAX(o.created_at) AS last_order_at + FROM ac_store_orders o + LEFT JOIN ac_store_customers c ON c.email = o.email + WHERE c.id IS NULL + "; + if ($q !== '') { + $guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) "; + } + $guestSql .= " + GROUP BY o.email + ORDER BY MAX(o.created_at) DESC + LIMIT 500 + "; + $guestStmt = $db->prepare($guestSql); + if ($q !== '') { + $guestStmt->execute([':q' => $like]); + } else { + $guestStmt->execute(); + } + $guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($guests) { + $rows = array_merge($rows, $guests); + } + } catch (Throwable $e) { + } + + if ($rows) { + $ipHistoryMap = $this->loadCustomerIpHistory($db); + foreach ($rows as &$row) { + $emailKey = strtolower(trim((string)($row['email'] ?? ''))); + $row['ips'] = $ipHistoryMap[$emailKey] ?? []; + } + unset($row); + } + } + + return new Response($this->view->render('admin/customers.php', [ + 'title' => 'Store Customers', + 'customers' => $rows, + 'currency' => Settings::get('store_currency', 'GBP'), + 'q' => $q, + ])); + } + + public function adminOrders(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + $q = trim((string)($_GET['q'] ?? '')); + $like = '%' . $q . '%'; + + $rows = []; + $db = Database::get(); + if ($db instanceof PDO) { + try { + $sql = " + SELECT id, order_no, email, status, currency, total, created_at, customer_ip + FROM ac_store_orders + "; + if ($q !== '') { + $sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q "; + } + $sql .= " + ORDER BY created_at DESC + LIMIT 500 + "; + $stmt = $db->prepare($sql); + if ($q !== '') { + $stmt->execute([':q' => $like]); + } else { + $stmt->execute(); + } + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $rows = []; + } + } + + return new Response($this->view->render('admin/orders.php', [ + 'title' => 'Store Orders', + 'orders' => $rows, + 'q' => $q, + 'saved' => (string)($_GET['saved'] ?? ''), + 'error' => (string)($_GET['error'] ?? ''), + ])); + } + + public function adminOrderCreate(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $email = strtolower(trim((string)($_POST['email'] ?? ''))); + $currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP')))); + $total = (float)($_POST['total'] ?? 0); + $status = trim((string)($_POST['status'] ?? 'pending')); + $orderNo = trim((string)($_POST['order_no'] ?? '')); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']); + } + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { + $status = 'pending'; + } + if ($total < 0) { + $total = 0.0; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + if ($orderNo === '') { + $prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); + $orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999); + } + + try { + $customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total); + $stmt = $db->prepare(" + INSERT INTO ac_store_orders + (order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) + VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW()) + "); + $stmt->execute([ + ':order_no' => $orderNo, + ':customer_id' => $customerId > 0 ? $customerId : null, + ':email' => $email, + ':status' => $status, + ':currency' => $currency, + ':subtotal' => $total, + ':total' => $total, + ':provider' => 'manual', + ':payment_ref' => null, + ':customer_ip' => $this->clientIp(), + ':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']); + } + + public function adminOrderStatus(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + $status = trim((string)($_POST['status'] ?? '')); + if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1"); + $stmt->execute([ + ':status' => $status, + ':id' => $orderId, + ]); + $this->rebuildSalesChartCache(); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']); + } + + public function adminOrderDelete(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $db->beginTransaction(); + $db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]); + $db->commit(); + $this->rebuildSalesChartCache(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']); + } + + public function adminOrderRefund(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $stmt = $db->prepare(" + SELECT id, status, payment_provider, payment_ref, currency, total + FROM ac_store_orders + WHERE id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $orderId]); + $order = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']); + } + + $status = (string)($order['status'] ?? ''); + if ($status === 'refunded') { + return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); + } + if ($status !== 'paid') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']); + } + + $provider = strtolower(trim((string)($order['payment_provider'] ?? ''))); + $paymentRef = trim((string)($order['payment_ref'] ?? '')); + + if ($provider === 'paypal') { + if ($paymentRef === '') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']); + } + $clientId = trim((string)Settings::get('store_paypal_client_id', '')); + $secret = trim((string)Settings::get('store_paypal_secret', '')); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']); + } + $refund = $this->paypalRefundCapture( + $clientId, + $secret, + $this->isEnabledSetting(Settings::get('store_test_mode', '1')), + $paymentRef, + (string)($order['currency'] ?? 'GBP'), + (float)($order['total'] ?? 0) + ); + if (!($refund['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]); + } + } + + $upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1"); + $upd->execute([':id' => $orderId]); + $revoke = $db->prepare(" + UPDATE ac_store_download_tokens + SET downloads_used = download_limit, + expires_at = NOW() + WHERE order_id = :order_id + "); + $revoke->execute([':order_id' => $orderId]); + $this->rebuildSalesChartCache(); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); + } + + public function adminOrderView(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + + $orderId = (int)($_GET['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $order = null; + $items = []; + $downloadsByItem = []; + $downloadEvents = []; + try { + $orderStmt = $db->prepare(" + SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, created_at, updated_at + FROM ac_store_orders + WHERE id = :id + LIMIT 1 + "); + $orderStmt->execute([':id' => $orderId]); + $order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$order) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $itemStmt = $db->prepare(" + SELECT + oi.id, + oi.item_type, + oi.item_id, + oi.title_snapshot, + oi.unit_price_snapshot, + oi.currency_snapshot, + oi.qty, + oi.line_total, + oi.created_at, + t.id AS token_id, + t.download_limit, + t.downloads_used, + t.expires_at, + f.file_name, + f.file_url + FROM ac_store_order_items oi + LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id + LEFT JOIN ac_store_files f ON f.id = t.file_id + WHERE oi.order_id = :order_id + ORDER BY oi.id ASC + "); + $itemStmt->execute([':order_id' => $orderId]); + $items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $eventStmt = $db->prepare(" + SELECT + e.id, + e.order_item_id, + e.file_id, + e.ip_address, + e.user_agent, + e.downloaded_at, + f.file_name + FROM ac_store_download_events e + LEFT JOIN ac_store_files f ON f.id = e.file_id + WHERE e.order_id = :order_id + ORDER BY e.downloaded_at DESC + LIMIT 500 + "); + $eventStmt->execute([':order_id' => $orderId]); + $downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + } + + foreach ($downloadEvents as $event) { + $key = (int)($event['order_item_id'] ?? 0); + if ($key <= 0) { + continue; + } + if (!isset($downloadsByItem[$key])) { + $downloadsByItem[$key] = [ + 'count' => 0, + 'ips' => [], + ]; + } + $downloadsByItem[$key]['count']++; + $ip = trim((string)($event['ip_address'] ?? '')); + if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) { + $downloadsByItem[$key]['ips'][] = $ip; + } + } + + return new Response($this->view->render('admin/order.php', [ + 'title' => 'Order Detail', + 'order' => $order, + 'items' => $items, + 'downloads_by_item' => $downloadsByItem, + 'download_events' => $downloadEvents, + ])); + } + + public function adminInstall(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $db = Database::get(); + if ($db instanceof PDO) { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_release_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + purchase_label VARCHAR(120) 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_store_track_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_track_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + 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_store_files ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + scope_type ENUM('release','track') NOT NULL, + scope_id INT UNSIGNED NOT NULL, + file_url VARCHAR(1024) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_size BIGINT UNSIGNED NULL, + mime_type VARCHAR(128) NULL, + 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_store_customers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(140) NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NULL, + 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_store_orders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_no VARCHAR(32) NOT NULL UNIQUE, + customer_id INT UNSIGNED NULL, + email VARCHAR(190) NOT NULL, + status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending', + currency CHAR(3) NOT NULL DEFAULT 'GBP', + subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00, + total DECIMAL(10,2) NOT NULL DEFAULT 0.00, + discount_code VARCHAR(64) NULL, + discount_amount DECIMAL(10,2) NULL, + payment_provider VARCHAR(40) NULL, + payment_ref VARCHAR(120) 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_store_order_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id INT UNSIGNED NOT NULL, + item_type ENUM('release','track') NOT NULL, + item_id INT UNSIGNED NOT NULL, + title_snapshot VARCHAR(255) NOT NULL, + unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP', + qty INT UNSIGNED NOT NULL DEFAULT 1, + line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_tokens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NOT NULL, + token VARCHAR(96) NOT NULL UNIQUE, + download_limit INT UNSIGNED NOT NULL DEFAULT 5, + downloads_used INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + token_id INT UNSIGNED NOT NULL, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_download_events_order (order_id), + KEY idx_store_download_events_item (order_item_id), + KEY idx_store_download_events_ip (ip_address) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(190) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + request_ip VARCHAR(64) NULL, + request_user_agent VARCHAR(255) NULL, + used_ip VARCHAR(64) NULL, + used_user_agent VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_login_tokens_email (email), + KEY idx_store_login_tokens_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); + } catch (Throwable $e) { + } + } catch (Throwable $e) { + } + } + + $this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot())); + $this->ensureSalesChartSchema(); + return new Response('', 302, ['Location' => '/admin/store']); + } + + public function accountIndex(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $this->ensureAnalyticsSchema(); + + $email = strtolower(trim((string)($_SESSION['ac_store_customer_email'] ?? ''))); + $flash = (string)($_GET['message'] ?? ''); + $error = (string)($_GET['error'] ?? ''); + $orders = []; + $downloads = []; + + if ($email !== '') { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $orderStmt = $db->prepare(" + SELECT id, order_no, status, currency, total, created_at + FROM ac_store_orders + WHERE email = :email + ORDER BY created_at DESC + LIMIT 100 + "); + $orderStmt->execute([':email' => $email]); + $orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $orders = []; + } + + try { + $downloadStmt = $db->prepare(" + SELECT + o.order_no, + f.file_name, + t.download_limit, + t.downloads_used, + t.expires_at, + t.token + FROM ac_store_download_tokens t + JOIN ac_store_orders o ON o.id = t.order_id + JOIN ac_store_files f ON f.id = t.file_id + WHERE t.email = :email + ORDER BY t.created_at DESC + LIMIT 500 + "); + $downloadStmt->execute([':email' => $email]); + $rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as $row) { + $token = trim((string)($row['token'] ?? '')); + if ($token === '') { + continue; + } + $downloads[] = [ + 'order_no' => (string)($row['order_no'] ?? ''), + 'file_name' => (string)($row['file_name'] ?? 'Download'), + 'download_limit' => (int)($row['download_limit'] ?? 0), + 'downloads_used' => (int)($row['downloads_used'] ?? 0), + 'expires_at' => (string)($row['expires_at'] ?? ''), + 'url' => '/store/download?token=' . rawurlencode($token), + ]; + } + } catch (Throwable $e) { + $downloads = []; + } + } + } + + return new Response($this->view->render('site/account.php', [ + 'title' => 'Account', + 'is_logged_in' => ($email !== ''), + 'email' => $email, + 'orders' => $orders, + 'downloads' => $downloads, + 'message' => $flash, + 'error' => $error, + 'download_limit' => (int)Settings::get('store_download_limit', '5'), + 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), + ])); + } + + public function accountRequestLogin(): Response + { + $email = strtolower(trim((string)($_POST['email'] ?? ''))); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/account?error=Enter+a+valid+email+address']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); + } + $this->ensureAnalyticsSchema(); + + try { + // Rate limit token requests per email. + $limitStmt = $db->prepare(" + SELECT COUNT(*) AS c + FROM ac_store_login_tokens + WHERE email = :email + AND created_at >= (NOW() - INTERVAL 10 MINUTE) + "); + $limitStmt->execute([':email' => $email]); + $limitRow = $limitStmt->fetch(PDO::FETCH_ASSOC); + if ((int)($limitRow['c'] ?? 0) >= 5) { + return new Response('', 302, ['Location' => '/account?error=Too+many+login+requests.+Please+wait+10+minutes']); + } + + // Send generic response even if no orders exist. + $orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email"); + $orderStmt->execute([':email' => $email]); + $orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0)); + + if ($orderCount > 0) { + $rawToken = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $rawToken); + $expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s'); + + $ins = $db->prepare(" + INSERT INTO ac_store_login_tokens + (email, token_hash, expires_at, request_ip, request_user_agent, created_at) + VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW()) + "); + $ins->execute([ + ':email' => $email, + ':token_hash' => $tokenHash, + ':expires_at' => $expiresAt, + ':request_ip' => $this->clientIp(), + ':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + $siteName = trim((string)Settings::get('site_title', '')); + if ($siteName === '') { + $siteName = 'AudioCore V1.5'; + } + $loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken); + $subject = $siteName . ' account access link'; + $html = '

Hello,

' + . '

Use this secure link to access your downloads:

' + . '

' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '

' + . '

This link expires in 15 minutes and can only be used once.

'; + $mailSettings = [ + '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', 'AudioCore'), + ]; + Mailer::send($email, $subject, $html, $mailSettings); + } + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/account?error=Unable+to+send+login+email+right+now']); + } + + return new Response('', 302, ['Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent']); + } + + public function accountLogin(): Response + { + $token = trim((string)($_GET['token'] ?? '')); + if ($token === '') { + return new Response('', 302, ['Location' => '/account?error=Invalid+login+token']); + } + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); + } + $this->ensureAnalyticsSchema(); + + try { + $hash = hash('sha256', $token); + $stmt = $db->prepare(" + SELECT id, email, expires_at, used_at + FROM ac_store_login_tokens + WHERE token_hash = :token_hash + LIMIT 1 + "); + $stmt->execute([':token_hash' => $hash]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return new Response('', 302, ['Location' => '/account?error=Login+link+is+invalid']); + } + if (!empty($row['used_at'])) { + return new Response('', 302, ['Location' => '/account?error=Login+link+has+already+been+used']); + } + $expiresAt = (string)($row['expires_at'] ?? ''); + if ($expiresAt !== '') { + if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) { + return new Response('', 302, ['Location' => '/account?error=Login+link+has+expired']); + } + } + + $upd = $db->prepare(" + UPDATE ac_store_login_tokens + SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent + WHERE id = :id + "); + $upd->execute([ + ':id' => (int)$row['id'], + ':used_ip' => $this->clientIp(), + ':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $_SESSION['ac_store_customer_email'] = strtolower(trim((string)($row['email'] ?? ''))); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/account?error=Unable+to+complete+login']); + } + + return new Response('', 302, ['Location' => '/account?message=Signed+in+successfully']); + } + + public function accountLogout(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + unset($_SESSION['ac_store_customer_email']); + return new Response('', 302, ['Location' => '/account?message=You+have+been+signed+out']); + } + + public function cartAdd(): Response + { + $itemType = trim((string)($_POST['item_type'] ?? 'track')); + if (!in_array($itemType, ['track', 'release'], true)) { + $itemType = 'track'; + } + + $itemId = (int)($_POST['item_id'] ?? 0); + $title = trim((string)($_POST['title'] ?? 'Item')); + $coverUrl = trim((string)($_POST['cover_url'] ?? '')); + $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP'))); + $price = (float)($_POST['price'] ?? 0); + $qty = max(1, (int)($_POST['qty'] ?? 1)); + $returnUrl = trim((string)($_POST['return_url'] ?? '/releases')); + $itemTitle = $title !== '' ? $title : 'Item'; + + if ($itemId <= 0 || $price <= 0) { + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart)) { + $cart = []; + } + + if ($itemType === 'release') { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $trackStmt = $db->prepare(" + SELECT t.id, + t.title, + t.mix_name, + COALESCE(sp.track_price, 0.00) AS track_price, + COALESCE(sp.currency, :currency) AS currency + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id = :release_id + AND sp.is_enabled = 1 + AND sp.track_price > 0 + ORDER BY t.track_no ASC, t.id ASC + "); + $trackStmt->execute([ + ':release_id' => $itemId, + ':currency' => $currency, + ]); + $trackRows = $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($trackRows) { + $releaseKey = 'release:' . $itemId; + $removedTracks = 0; + foreach ($trackRows as $row) { + $trackId = (int)($row['id'] ?? 0); + if ($trackId <= 0) { + continue; + } + $trackKey = 'track:' . $trackId; + if (isset($cart[$trackKey])) { + unset($cart[$trackKey]); + $removedTracks++; + } + } + + $cart[$releaseKey] = [ + 'key' => $releaseKey, + 'item_type' => 'release', + 'item_id' => $itemId, + 'title' => $itemTitle, + 'cover_url' => $coverUrl, + 'price' => $price, + 'currency' => $currency, + 'qty' => 1, + ]; + $_SESSION['ac_cart'] = $cart; + $msg = '"' . $itemTitle . '" added as full release.'; + if ($removedTracks > 0) { + $msg .= ' Removed ' . $removedTracks . ' individual track' . ($removedTracks === 1 ? '' : 's') . ' from cart.'; + } + $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => $msg]; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + } catch (Throwable $e) { + } + } + } + + $key = $itemType . ':' . $itemId; + if (isset($cart[$key]) && is_array($cart[$key])) { + if ($itemType === 'track') { + $_SESSION['ac_site_notice'] = [ + 'type' => 'info', + 'text' => '"' . $itemTitle . '" is already in your cart.', + ]; + } else { + $cart[$key]['qty'] = max(1, (int)($cart[$key]['qty'] ?? 1)) + $qty; + $cart[$key]['price'] = $price; + $cart[$key]['currency'] = $currency; + $cart[$key]['title'] = $itemTitle; + if ($coverUrl !== '') { + $cart[$key]['cover_url'] = $coverUrl; + } + $_SESSION['ac_site_notice'] = [ + 'type' => 'ok', + 'text' => '"' . $itemTitle . '" quantity updated in your cart.', + ]; + } + } else { + $cart[$key] = [ + 'key' => $key, + 'item_type' => $itemType, + 'item_id' => $itemId, + 'title' => $itemTitle, + 'cover_url' => $coverUrl, + 'price' => $price, + 'currency' => $currency, + 'qty' => $qty, + ]; + $_SESSION['ac_site_notice'] = [ + 'type' => 'ok', + 'text' => '"' . $itemTitle . '" added to your cart.', + ]; + } + $_SESSION['ac_cart'] = $cart; + + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartApplyDiscount(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $code = strtoupper(trim((string)($_POST['discount_code'] ?? ''))); + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + if ($code === '') { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Enter a discount code first.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount service unavailable right now.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $this->ensureDiscountSchema(); + $discount = $this->loadActiveDiscount($db, $code); + if (!$discount) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'That discount code is invalid or expired.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $_SESSION['ac_discount_code'] = (string)$discount['code']; + $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => 'Discount code "' . (string)$discount['code'] . '" applied.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartClearDiscount(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + unset($_SESSION['ac_discount_code']); + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount code removed.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartIndex(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart)) { + $cart = []; + } + + $items = array_values(array_filter($cart, static function ($item): bool { + return is_array($item); + })); + + $db = Database::get(); + if ($db instanceof PDO) { + foreach ($items as $idx => $item) { + $cover = trim((string)($item['cover_url'] ?? '')); + if ($cover !== '') { + continue; + } + $itemType = (string)($item['item_type'] ?? ''); + $itemId = (int)($item['item_id'] ?? 0); + if ($itemId <= 0) { + continue; + } + try { + if ($itemType === 'track') { + $stmt = $db->prepare(" + SELECT r.cover_url + FROM ac_release_tracks t + JOIN ac_releases r ON r.id = t.release_id + WHERE t.id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $itemId]); + } elseif ($itemType === 'release') { + $stmt = $db->prepare("SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $itemId]); + } else { + continue; + } + $row = $stmt->fetch(PDO::FETCH_ASSOC); + $coverUrl = trim((string)($row['cover_url'] ?? '')); + if ($coverUrl !== '') { + $items[$idx]['cover_url'] = $coverUrl; + } + } catch (Throwable $e) { + } + } + } + + $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); + $totals = $this->buildCartTotals($items, $discountCode); + if ($totals['discount_code'] === '') { + unset($_SESSION['ac_discount_code']); + } else { + $_SESSION['ac_discount_code'] = $totals['discount_code']; + } + + return new Response($this->view->render('site/cart.php', [ + 'title' => 'Cart', + 'items' => $items, + 'totals' => $totals, + ])); + } + + public function cartRemove(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $key = trim((string)($_POST['key'] ?? '')); + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + + if ($key !== '' && isset($_SESSION['ac_cart']) && is_array($_SESSION['ac_cart'])) { + unset($_SESSION['ac_cart'][$key]); + } + + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function checkoutIndex(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart)) { + $cart = []; + } + $items = array_values(array_filter($cart, static function ($item): bool { + return is_array($item); + })); + $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); + $totals = $this->buildCartTotals($items, $discountCode); + if ($totals['discount_code'] === '') { + unset($_SESSION['ac_discount_code']); + } else { + $_SESSION['ac_discount_code'] = $totals['discount_code']; + } + $total = (float)$totals['amount']; + $currency = (string)$totals['currency']; + + $downloadLinks = []; + $downloadNotice = ''; + $success = (string)($_GET['success'] ?? ''); + $orderNo = (string)($_GET['order_no'] ?? ''); + if ($success !== '' && $orderNo !== '') { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $orderStmt = $db->prepare("SELECT id, status, email FROM ac_store_orders WHERE order_no = :order_no LIMIT 1"); + $orderStmt->execute([':order_no' => $orderNo]); + $orderRow = $orderStmt->fetch(PDO::FETCH_ASSOC); + if ($orderRow) { + $orderId = (int)($orderRow['id'] ?? 0); + $orderStatus = (string)($orderRow['status'] ?? ''); + if ($orderId > 0) { + $tokenStmt = $db->prepare(" + SELECT t.token, f.file_name + FROM ac_store_download_tokens t + JOIN ac_store_files f ON f.id = t.file_id + WHERE t.order_id = :order_id + ORDER BY t.id DESC + "); + $tokenStmt->execute([':order_id' => $orderId]); + $rows = $tokenStmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $token = trim((string)($row['token'] ?? '')); + if ($token === '') { + continue; + } + $downloadLinks[] = [ + 'label' => (string)($row['file_name'] ?? 'Download'), + 'url' => '/store/download?token=' . rawurlencode($token), + ]; + } + if (!$downloadLinks) { + $downloadNotice = $orderStatus === 'paid' + ? 'No downloadable files are attached to this order yet.' + : 'Download links will appear here once payment is confirmed.'; + } + } + } + } catch (Throwable $e) { + $downloadNotice = 'Unable to load download links right now.'; + } + } + } + + return new Response($this->view->render('site/checkout.php', [ + 'title' => 'Checkout', + 'items' => $items, + 'total' => $total, + 'subtotal' => (float)$totals['subtotal'], + 'discount_amount' => (float)$totals['discount_amount'], + 'discount_code' => (string)$totals['discount_code'], + 'currency' => $currency, + 'success' => $success, + 'order_no' => $orderNo, + 'error' => (string)($_GET['error'] ?? ''), + 'download_links' => $downloadLinks, + 'download_notice' => $downloadNotice, + 'download_limit' => (int)Settings::get('store_download_limit', '5'), + 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), + ])); + } + + public function checkoutSandbox(): Response + { + return $this->checkoutPlace(); + } + + public function checkoutPlace(): Response + { + if (!isset($_POST['accept_terms'])) { + return new Response('', 302, ['Location' => '/checkout?error=Please+accept+the+terms+to+continue']); + } + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart) || !$cart) { + return new Response('', 302, ['Location' => '/checkout']); + } + + $items = array_values(array_filter($cart, static function ($item): bool { + return is_array($item); + })); + if (!$items) { + return new Response('', 302, ['Location' => '/checkout']); + } + + $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); + $totals = $this->buildCartTotals($items, $discountCode); + $currency = (string)$totals['currency']; + $subtotal = (float)$totals['subtotal']; + $discountAmount = (float)$totals['discount_amount']; + $total = (float)$totals['amount']; + $appliedDiscountCode = (string)$totals['discount_code']; + $testMode = $this->isEnabledSetting(Settings::get('store_test_mode', '1')); + $paypalEnabled = $this->isEnabledSetting(Settings::get('store_paypal_enabled', '0')); + $orderPrefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); + $orderNo = $orderPrefix . '-' . date('YmdHis') . '-' . random_int(100, 999); + $email = trim((string)($_POST['email'] ?? '')); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/checkout?error=Please+enter+a+valid+email+address']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/checkout']); + } + $this->ensureAnalyticsSchema(); + $customerIp = $this->clientIp(); + $customerUserAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); + + try { + $customerId = $this->upsertCustomerFromOrder($db, $email, $customerIp, $customerUserAgent, $total); + $db->beginTransaction(); + $insOrder = $db->prepare(" + INSERT INTO ac_store_orders + (order_no, customer_id, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) + VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :discount_code, :discount_amount, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW()) + "); + $insOrder->execute([ + ':order_no' => $orderNo, + ':customer_id' => $customerId > 0 ? $customerId : null, + ':email' => $email, + ':status' => $paypalEnabled ? 'pending' : ($testMode ? 'paid' : 'pending'), + ':currency' => $currency, + ':subtotal' => $subtotal, + ':total' => $total, + ':discount_code' => $appliedDiscountCode !== '' ? $appliedDiscountCode : null, + ':discount_amount' => $discountAmount > 0 ? $discountAmount : null, + ':provider' => $paypalEnabled ? 'paypal' : ($testMode ? 'test' : 'checkout'), + ':payment_ref' => $paypalEnabled ? null : ($testMode ? 'test' : null), + ':customer_ip' => $customerIp, + ':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null, + ]); + $orderId = (int)$db->lastInsertId(); + + $insItem = $db->prepare(" + INSERT INTO ac_store_order_items + (order_id, item_type, item_id, title_snapshot, unit_price_snapshot, currency_snapshot, qty, line_total, created_at) + VALUES (:order_id, :item_type, :item_id, :title, :price, :currency, :qty, :line_total, NOW()) + "); + foreach ($items as $item) { + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $insItem->execute([ + ':order_id' => $orderId, + ':item_type' => (string)($item['item_type'] ?? 'track'), + ':item_id' => (int)($item['item_id'] ?? 0), + ':title' => (string)($item['title'] ?? 'Item'), + ':price' => $price, + ':currency' => (string)($item['currency'] ?? $currency), + ':qty' => $qty, + ':line_total' => ($price * $qty), + ]); + } + $db->commit(); + if ($total <= 0.0) { + try { + $upd = $db->prepare(" + UPDATE ac_store_orders + SET status = 'paid', payment_provider = 'discount', payment_ref = :payment_ref, updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':payment_ref' => 'discount-zero-total', + ':id' => $orderId, + ]); + } catch (Throwable $e) { + } + $_SESSION['ac_last_order_no'] = $orderNo; + $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid'); + $this->sendOrderEmail($email, $orderNo, $currency, 0.0, $items, 'paid', $downloadLinksHtml); + if ($appliedDiscountCode !== '') { + $this->bumpDiscountUsage($db, $appliedDiscountCode); + } + $this->rebuildSalesChartCache(); + $_SESSION['ac_cart'] = []; + unset($_SESSION['ac_discount_code']); + return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + } + if ($paypalEnabled) { + $clientId = trim((string)Settings::get('store_paypal_client_id', '')); + $secret = trim((string)Settings::get('store_paypal_secret', '')); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing']); + } + + $returnUrl = $this->baseUrl() . '/checkout/paypal/return'; + $cancelUrl = $this->baseUrl() . '/checkout/paypal/cancel'; + $create = $this->paypalCreateOrder( + $clientId, + $secret, + $testMode, + $currency, + $total, + $orderNo, + $returnUrl, + $cancelUrl + ); + if (!($create['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($create['error'] ?? 'Unable to start PayPal checkout'))]); + } + + $paypalOrderId = (string)($create['order_id'] ?? ''); + $approvalUrl = (string)($create['approval_url'] ?? ''); + if ($paypalOrderId === '' || $approvalUrl === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+url']); + } + + try { + $upd = $db->prepare(" + UPDATE ac_store_orders + SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':payment_ref' => $paypalOrderId, + ':id' => $orderId, + ]); + } catch (Throwable $e) { + } + + return new Response('', 302, ['Location' => $approvalUrl]); + } + + $_SESSION['ac_last_order_no'] = $orderNo; + $status = $testMode ? 'paid' : 'pending'; + $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, $status); + $this->sendOrderEmail($email, $orderNo, $currency, $total, $items, $status, $downloadLinksHtml); + if ($testMode) { + if ($appliedDiscountCode !== '') { + $this->bumpDiscountUsage($db, $appliedDiscountCode); + } + $this->rebuildSalesChartCache(); + $_SESSION['ac_cart'] = []; + unset($_SESSION['ac_discount_code']); + return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + } + return new Response('', 302, ['Location' => '/checkout?error=No+live+payment+gateway+is+enabled']); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return new Response('', 302, ['Location' => '/checkout']); + } + } + + public function checkoutPaypalReturn(): Response + { + $paypalOrderId = trim((string)($_GET['token'] ?? '')); + if ($paypalOrderId === '') { + return new Response('', 302, ['Location' => '/checkout?error=Missing+PayPal+order+token']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']); + } + + $orderStmt = $db->prepare(" + SELECT id, order_no, email, status, currency, total, discount_code + FROM ac_store_orders + WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref + LIMIT 1 + "); + $orderStmt->execute([':payment_ref' => $paypalOrderId]); + $order = $orderStmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']); + } + + $orderId = (int)($order['id'] ?? 0); + $orderNo = (string)($order['order_no'] ?? ''); + $email = (string)($order['email'] ?? ''); + $status = (string)($order['status'] ?? 'pending'); + if ($orderId <= 0 || $orderNo === '') { + return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']); + } + + if ($status === 'paid') { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $_SESSION['ac_last_order_no'] = $orderNo; + $_SESSION['ac_cart'] = []; + unset($_SESSION['ac_discount_code']); + return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + } + + $clientId = trim((string)Settings::get('store_paypal_client_id', '')); + $secret = trim((string)Settings::get('store_paypal_secret', '')); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']); + } + + $capture = $this->paypalCaptureOrder( + $clientId, + $secret, + $this->isEnabledSetting(Settings::get('store_test_mode', '1')), + $paypalOrderId + ); + if (!($capture['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]); + } + + try { + $captureRef = trim((string)($capture['capture_id'] ?? '')); + $upd = $db->prepare(" + UPDATE ac_store_orders + SET status = 'paid', payment_ref = :payment_ref, updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':id' => $orderId, + ':payment_ref' => $captureRef !== '' ? $captureRef : $paypalOrderId, + ]); + } catch (Throwable $e) { + } + + $itemsForEmail = $this->orderItemsForEmail($db, $orderId); + $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid'); + $discountCode = trim((string)($order['discount_code'] ?? '')); + if ($discountCode !== '') { + $this->bumpDiscountUsage($db, $discountCode); + } + $this->rebuildSalesChartCache(); + $this->sendOrderEmail( + $email, + $orderNo, + (string)($order['currency'] ?? 'GBP'), + (float)($order['total'] ?? 0), + $itemsForEmail, + 'paid', + $downloadLinksHtml + ); + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $_SESSION['ac_last_order_no'] = $orderNo; + $_SESSION['ac_cart'] = []; + unset($_SESSION['ac_discount_code']); + return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + } + + public function checkoutPaypalCancel(): Response + { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']); + } + + public function download(): Response + { + $token = trim((string)($_GET['token'] ?? '')); + if ($token === '') { + return new Response('Invalid download token.', 400); + } + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('Download service unavailable.', 500); + } + $this->ensureAnalyticsSchema(); + try { + $stmt = $db->prepare(" + SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at, + f.file_url, f.file_name, f.mime_type, o.status AS order_status + FROM ac_store_download_tokens t + JOIN ac_store_files f ON f.id = t.file_id + JOIN ac_store_orders o ON o.id = t.order_id + WHERE t.token = :token + LIMIT 1 + "); + $stmt->execute([':token' => $token]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return new Response('Download link is invalid.', 404); + } + $orderStatus = strtolower(trim((string)($row['order_status'] ?? ''))); + if ($orderStatus !== 'paid') { + return new Response('Downloads are no longer available for this order.', 410); + } + + $limit = (int)($row['download_limit'] ?? 0); + $used = (int)($row['downloads_used'] ?? 0); + if ($limit > 0 && $used >= $limit) { + return new Response('Download limit reached.', 410); + } + + $expiresAt = (string)($row['expires_at'] ?? ''); + if ($expiresAt !== '') { + try { + if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) { + return new Response('Download link expired.', 410); + } + } catch (Throwable $e) { + } + } + + $relative = ltrim((string)($row['file_url'] ?? ''), '/'); + if ($relative === '' || str_contains($relative, '..')) { + return new Response('Invalid file path.', 400); + } + $root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/'); + $path = $root . '/' . $relative; + if (!is_file($path) || !is_readable($path)) { + return new Response('File not found.', 404); + } + + $upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id"); + $upd->execute([':id' => (int)$row['id']]); + try { + $evt = $db->prepare(" + INSERT INTO ac_store_download_events + (token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at) + VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW()) + "); + $evt->execute([ + ':token_id' => (int)($row['id'] ?? 0), + ':order_id' => (int)($row['order_id'] ?? 0), + ':order_item_id' => (int)($row['order_item_id'] ?? 0), + ':file_id' => (int)($row['file_id'] ?? 0), + ':email' => (string)($row['email'] ?? ''), + ':ip_address' => $this->clientIp(), + ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + } + + $fileName = (string)($row['file_name'] ?? basename($path)); + $mime = (string)($row['mime_type'] ?? ''); + if ($mime === '') { + $mime = 'application/octet-stream'; + } + header('Content-Type: ' . $mime); + header('Content-Length: ' . (string)filesize($path)); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"'); + readfile($path); + exit; + } catch (Throwable $e) { + return new Response('Download failed.', 500); + } + } + + private function guard(): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } + + private function ensureAnalyticsSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + $this->ensureSalesChartSchema(); + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + token_id INT UNSIGNED NOT NULL, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_download_events_order (order_id), + KEY idx_store_download_events_item (order_item_id), + KEY idx_store_download_events_ip (ip_address) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(190) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + request_ip VARCHAR(64) NULL, + request_user_agent VARCHAR(255) NULL, + used_ip VARCHAR(64) NULL, + used_user_agent VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_login_tokens_email (email), + KEY idx_store_login_tokens_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); + } catch (Throwable $e) { + } + } + + private function tablesReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $db->query("SELECT 1 FROM ac_store_release_products LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_track_products LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_files LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_orders LIMIT 1"); + return true; + } catch (Throwable $e) { + return false; + } + } + + private function privateRoot(): string + { + return '/home/audiocore.site/private_downloads'; + } + + private function ensurePrivateRoot(string $path): bool + { + $path = rtrim($path, '/'); + if ($path === '') { + return false; + } + if (!is_dir($path) && !mkdir($path, 0755, true)) { + return false; + } + if (!is_writable($path)) { + return false; + } + $tracks = $path . '/tracks'; + if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) { + return false; + } + return is_writable($tracks); + } + + private function privateRootReady(): bool + { + $path = Settings::get('store_private_root', $this->privateRoot()); + $path = rtrim($path, '/'); + if ($path === '' || !is_dir($path) || !is_writable($path)) { + return false; + } + $tracks = $path . '/tracks'; + return is_dir($tracks) && is_writable($tracks); + } + + private function settingsPayload(): array + { + $cronKey = trim((string)Settings::get('store_sales_chart_cron_key', '')); + if ($cronKey === '') { + try { + $cronKey = bin2hex(random_bytes(24)); + Settings::set('store_sales_chart_cron_key', $cronKey); + } catch (Throwable $e) { + $cronKey = ''; + } + } + return [ + 'store_currency' => Settings::get('store_currency', 'GBP'), + 'store_private_root' => Settings::get('store_private_root', $this->privateRoot()), + 'store_download_limit' => Settings::get('store_download_limit', '5'), + 'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'), + 'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'), + 'store_test_mode' => Settings::get('store_test_mode', '1'), + 'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'), + 'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''), + 'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''), + 'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'), + 'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''), + 'store_paypal_secret' => Settings::get('store_paypal_secret', ''), + 'store_email_logo_url' => Settings::get('store_email_logo_url', ''), + 'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'), + 'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()), + 'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'), + 'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'), + 'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'), + 'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'), + 'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'), + 'store_sales_chart_cron_key' => $cronKey, + ]; + } + + private function ensureSalesChartSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + chart_scope ENUM('tracks','releases') NOT NULL, + chart_window ENUM('latest','weekly','all_time') NOT NULL, + rank_no INT UNSIGNED NOT NULL, + item_key VARCHAR(190) NOT NULL, + item_label VARCHAR(255) NOT NULL, + units INT UNSIGNED NOT NULL DEFAULT 0, + revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00, + snapshot_from DATETIME NULL, + snapshot_to DATETIME NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no), + KEY idx_sales_chart_item (chart_scope, chart_window, item_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + public function rebuildSalesChartCache(): bool + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return false; + } + $this->ensureSalesChartSchema(); + + $latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24'))); + $now = new \DateTimeImmutable('now'); + $ranges = [ + 'latest' => [ + 'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'), + 'to' => $now->format('Y-m-d H:i:s'), + ], + 'weekly' => [ + 'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'), + 'to' => $now->format('Y-m-d H:i:s'), + ], + 'all_time' => [ + 'from' => null, + 'to' => $now->format('Y-m-d H:i:s'), + ], + ]; + + $maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2)); + + try { + $db->beginTransaction(); + $delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window"); + $insert = $db->prepare(" + INSERT INTO ac_store_sales_chart_cache + (chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at) + VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at) + "); + + foreach (['tracks', 'releases'] as $scope) { + foreach ($ranges as $window => $range) { + $delete->execute([':scope' => $scope, ':window' => $window]); + $rows = $scope === 'tracks' + ? $this->salesChartTrackRows($db, $range['from'], $maxRows) + : $this->salesChartReleaseRows($db, $range['from'], $maxRows); + + $rank = 1; + foreach ($rows as $row) { + $itemKey = trim((string)($row['item_key'] ?? '')); + $itemLabel = trim((string)($row['item_label'] ?? '')); + if ($itemLabel === '') { + continue; + } + if ($itemKey === '') { + $itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? ''); + } + $insert->execute([ + ':chart_scope' => $scope, + ':chart_window' => $window, + ':rank_no' => $rank, + ':item_key' => substr($itemKey, 0, 190), + ':item_label' => substr($itemLabel, 0, 255), + ':units' => max(0, (int)($row['units'] ?? 0)), + ':revenue' => round((float)($row['revenue'] ?? 0), 2), + ':snapshot_from' => $range['from'], + ':snapshot_to' => $range['to'], + ':updated_at' => $now->format('Y-m-d H:i:s'), + ]); + $rank++; + } + } + } + + $db->commit(); + return true; + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return false; + } + } + + private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array + { + $sql = " + SELECT + CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key, + COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label, + SUM(oi.qty) AS units, + SUM(oi.line_total) AS revenue + FROM ac_store_order_items oi + JOIN ac_store_orders o ON o.id = oi.order_id + LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id + WHERE o.status = 'paid' + AND oi.item_type = 'track' + "; + if ($from !== null) { + $sql .= " AND o.created_at >= :from "; + } + $sql .= " + GROUP BY oi.item_id + ORDER BY units DESC, revenue DESC, item_label ASC + LIMIT :lim + "; + $stmt = $db->prepare($sql); + if ($from !== null) { + $stmt->bindValue(':from', $from, PDO::PARAM_STR); + } + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array + { + $sql = " + SELECT + CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key, + COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label, + SUM(oi.qty) AS units, + SUM(oi.line_total) AS revenue + FROM ac_store_order_items oi + JOIN ac_store_orders o ON o.id = oi.order_id + LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id + LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id + LEFT JOIN ac_releases r ON rt.release_id = r.id + LEFT JOIN ( + SELECT id, id AS release_id FROM ac_releases + ) ri ON oi.item_type = 'release' AND oi.item_id = ri.id + WHERE o.status = 'paid' + "; + if ($from !== null) { + $sql .= " AND o.created_at >= :from "; + } + $sql .= " + GROUP BY COALESCE(ri.release_id, r.id, oi.item_id) + ORDER BY units DESC, revenue DESC, item_label ASC + LIMIT :lim + "; + $stmt = $db->prepare($sql); + if ($from !== null) { + $stmt->bindValue(':from', $from, PDO::PARAM_STR); + } + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function salesChartRows(string $scope, string $window, int $limit): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->prepare(" + SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at + FROM ac_store_sales_chart_cache + WHERE chart_scope = :scope + AND chart_window = :window + ORDER BY rank_no ASC + LIMIT :lim + "); + $stmt->bindValue(':scope', $scope, PDO::PARAM_STR); + $stmt->bindValue(':window', $window, PDO::PARAM_STR); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + return []; + } + } + + private function salesChartLastRebuildAt(): ?string + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return null; + } + try { + $stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $value = trim((string)($row['updated_at'] ?? '')); + return $value !== '' ? $value : null; + } catch (Throwable $e) { + return null; + } + } + + private function salesChartCronUrl(): string + { + $base = $this->baseUrl(); + $key = trim((string)Settings::get('store_sales_chart_cron_key', '')); + if ($base === '' || $key === '') { + return ''; + } + return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key); + } + + private function salesChartCronCommand(): string + { + $url = $this->salesChartCronUrl(); + $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180'))); + $step = max(1, (int)floor($minutes / 60)); + $prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *'; + if ($url === '') { + return ''; + } + return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1"; + } + + private function ensureDiscountSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_discount_codes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent', + discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00, + max_uses INT UNSIGNED NOT NULL DEFAULT 0, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME NULL, + 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 adminDiscountRows(): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->query(" + SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at + FROM ac_store_discount_codes + ORDER BY created_at DESC + LIMIT 300 + "); + return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + } catch (Throwable $e) { + return []; + } + } + + private function loadActiveDiscount(PDO $db, string $code): ?array + { + try { + $stmt = $db->prepare(" + SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active + FROM ac_store_discount_codes + WHERE code = :code + LIMIT 1 + "); + $stmt->execute([':code' => strtoupper(trim($code))]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + if ((int)($row['is_active'] ?? 0) !== 1) { + return null; + } + $maxUses = (int)($row['max_uses'] ?? 0); + $used = (int)($row['used_count'] ?? 0); + if ($maxUses > 0 && $used >= $maxUses) { + return null; + } + $expires = trim((string)($row['expires_at'] ?? '')); + if ($expires !== '') { + try { + if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) { + return null; + } + } catch (Throwable $e) { + return null; + } + } + return $row; + } catch (Throwable $e) { + return null; + } + } + + private function buildCartTotals(array $items, string $discountCode = ''): array + { + $totals = [ + 'count' => 0, + 'subtotal' => 0.0, + 'discount_amount' => 0.0, + 'amount' => 0.0, + 'currency' => Settings::get('store_currency', 'GBP'), + 'discount_code' => '', + ]; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $totals['count'] += $qty; + $totals['subtotal'] += ($price * $qty); + if (!empty($item['currency'])) { + $totals['currency'] = (string)$item['currency']; + } + } + + $discountCode = strtoupper(trim($discountCode)); + if ($discountCode !== '' && $totals['subtotal'] > 0) { + $db = Database::get(); + if ($db instanceof PDO) { + $this->ensureDiscountSchema(); + $discount = $this->loadActiveDiscount($db, $discountCode); + if ($discount) { + $discountType = (string)($discount['discount_type'] ?? 'percent'); + $discountValue = (float)($discount['discount_value'] ?? 0); + if ($discountType === 'fixed') { + $totals['discount_amount'] = min($totals['subtotal'], max(0, round($discountValue, 2))); + } else { + $percent = min(100, max(0, $discountValue)); + $totals['discount_amount'] = min($totals['subtotal'], round($totals['subtotal'] * ($percent / 100), 2)); + } + $totals['discount_code'] = (string)($discount['code'] ?? ''); + } + } + } + + $totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2)); + return $totals; + } + + private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void + { + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return; + } + $subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'); + $htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()); + $itemsHtml = $this->renderItemsHtml($items, $currency); + $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); + $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); + $logoHtml = $logoUrl !== '' + ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' + : ''; + if ($downloadLinksHtml === '') { + $downloadLinksHtml = $status === 'paid' + ? '

Your download links will be available in your account/downloads area.

' + : '

Download links are sent once payment is confirmed.

'; + } + $map = [ + '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), + '{{order_no}}' => htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'), + '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), + '{{currency}}' => htmlspecialchars($currency, ENT_QUOTES, 'UTF-8'), + '{{total}}' => number_format($total, 2), + '{{status}}' => htmlspecialchars($status, ENT_QUOTES, 'UTF-8'), + '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), + '{{logo_html}}' => $logoHtml, + '{{items_html}}' => $itemsHtml, + '{{download_links_html}}' => $downloadLinksHtml, + ]; + $subject = strtr($subjectTpl, $map); + $html = strtr($htmlTpl, $map); + $mailSettings = [ + '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', 'AudioCore'), + ]; + Mailer::send($to, $subject, $html, $mailSettings); + } + + private function sanitizeOrderPrefix(string $prefix): string + { + $prefix = strtoupper(trim($prefix)); + $prefix = preg_replace('/[^A-Z0-9-]+/', '-', $prefix) ?? 'AC-ORD'; + $prefix = trim($prefix, '-'); + if ($prefix === '') { + return 'AC-ORD'; + } + return substr($prefix, 0, 20); + } + + private function provisionDownloadTokens(PDO $db, int $orderId, string $email, string $status): string + { + if ($status !== 'paid') { + return '

Download links are sent once payment is confirmed.

'; + } + $downloadLimit = max(1, (int)Settings::get('store_download_limit', '5')); + $expiryDays = max(1, (int)Settings::get('store_download_expiry_days', '30')); + $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $expiryDays . ' days')->format('Y-m-d H:i:s'); + + try { + $itemStmt = $db->prepare("SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC"); + $itemStmt->execute([':order_id' => $orderId]); + $orderItems = $itemStmt->fetchAll(PDO::FETCH_ASSOC); + if (!$orderItems) { + return '

No downloadable items in this order.

'; + } + + $tokenStmt = $db->prepare(" + INSERT INTO ac_store_download_tokens + (order_id, order_item_id, file_id, email, token, download_limit, downloads_used, expires_at, created_at) + VALUES (:order_id, :order_item_id, :file_id, :email, :token, :download_limit, 0, :expires_at, NOW()) + "); + + $links = []; + foreach ($orderItems as $item) { + $type = (string)($item['item_type'] ?? ''); + $itemId = (int)($item['item_id'] ?? 0); + $orderItemId = (int)($item['id'] ?? 0); + if ($itemId <= 0 || $orderItemId <= 0) { + continue; + } + $files = []; + if ($type === 'release') { + $trackIdsStmt = $db->prepare(" + SELECT t.id + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id = :release_id AND sp.is_enabled = 1 + ORDER BY t.track_no ASC, t.id ASC + "); + $trackIdsStmt->execute([':release_id' => $itemId]); + $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackIdsStmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0)); + if ($trackIds) { + $placeholders = implode(',', array_fill(0, count($trackIds), '?')); + $fileStmt = $db->prepare(" + SELECT id, file_name + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id IN ($placeholders) AND is_active = 1 + ORDER BY id DESC + "); + $fileStmt->execute($trackIds); + $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + } else { + $fileStmt = $db->prepare(" + SELECT id, file_name + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id = :scope_id AND is_active = 1 + ORDER BY id DESC + "); + $fileStmt->execute([':scope_id' => $itemId]); + $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + foreach ($files as $file) { + $fileId = (int)($file['id'] ?? 0); + if ($fileId <= 0) { + continue; + } + $token = bin2hex(random_bytes(24)); + $tokenStmt->execute([ + ':order_id' => $orderId, + ':order_item_id' => $orderItemId, + ':file_id' => $fileId, + ':email' => $email, + ':token' => $token, + ':download_limit' => $downloadLimit, + ':expires_at' => $expiresAt, + ]); + $label = (string)($file['file_name'] ?? $item['title_snapshot'] ?? 'Download'); + $links[] = [ + 'url' => $this->baseUrl() . '/store/download?token=' . rawurlencode($token), + 'label' => $label, + ]; + } + } + if (!$links) { + return '

No downloadable files attached yet.

'; + } + $rows = []; + foreach ($links as $link) { + $rows[] = '
  • ' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '
  • '; + } + return '

    Your Downloads

    '; + } catch (Throwable $e) { + return '

    Download links could not be generated yet.

    '; + } + } + + private function renderItemsHtml(array $items, string $defaultCurrency): string + { + $rows = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $title = htmlspecialchars((string)($item['title'] ?? 'Item'), ENT_QUOTES, 'UTF-8'); + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $currency = htmlspecialchars((string)($item['currency'] ?? $defaultCurrency), ENT_QUOTES, 'UTF-8'); + $line = number_format($price * $qty, 2); + $rows[] = '' + . '' . $title . '' + . '' . $currency . ' ' . $line . '' + . ''; + } + if (!$rows) { + return '

    No items.

    '; + } + return '' . implode('', $rows) . '
    '; + } + + private function defaultOrderEmailHtml(): string + { + return '{{logo_html}}' + . '

    {{site_name}} - Order {{order_no}}

    ' + . '

    Status: {{status}}

    ' + . '

    Email: {{customer_email}}

    ' + . '{{items_html}}' + . '{{download_links_html}}' + . '

    Total: {{currency}} {{total}}

    '; + } + + private function safeReturnUrl(string $url): string + { + if ($url === '' || $url[0] !== '/') { + return '/releases'; + } + if (str_starts_with($url, '//')) { + return '/releases'; + } + return $url; + } + + private function baseUrl(): string + { + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); + $scheme = $https ? 'https' : 'http'; + $host = (string)($_SERVER['HTTP_HOST'] ?? ''); + if ($host === '') { + return ''; + } + return $scheme . '://' . $host; + } + + private function logMailDebug(string $ref, array $payload): void + { + try { + $dir = __DIR__ . '/../../storage/logs'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $file = $dir . '/store_mail.log'; + $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; + @file_put_contents($file, $line, FILE_APPEND); + } catch (Throwable $e) { + } + } + + private function clientIp(): string + { + $candidates = [ + (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), + (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), + (string)($_SERVER['REMOTE_ADDR'] ?? ''), + ]; + foreach ($candidates as $candidate) { + if ($candidate === '') { + continue; + } + $first = trim(explode(',', $candidate)[0] ?? ''); + if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) { + return $first; + } + } + return ''; + } + + private function loadCustomerIpHistory(PDO $db): array + { + $history = []; + try { + $stmt = $db->query(" + SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen + FROM ac_store_orders o + WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> '' + GROUP BY o.email, o.customer_ip + "); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + foreach ($rows as $row) { + $email = strtolower(trim((string)($row['email'] ?? ''))); + $ip = trim((string)($row['ip_address'] ?? '')); + $lastSeen = (string)($row['last_seen'] ?? ''); + if ($email === '' || $ip === '') { + continue; + } + $history[$email][$ip] = $lastSeen; + } + } catch (Throwable $e) { + } + + try { + $stmt = $db->query(" + SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen + FROM ac_store_download_events e + JOIN ac_store_download_tokens t ON t.id = e.token_id + WHERE e.ip_address IS NOT NULL AND e.ip_address <> '' + GROUP BY t.email, e.ip_address + "); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + foreach ($rows as $row) { + $email = strtolower(trim((string)($row['email'] ?? ''))); + $ip = trim((string)($row['ip_address'] ?? '')); + $lastSeen = (string)($row['last_seen'] ?? ''); + if ($email === '' || $ip === '') { + continue; + } + $existing = $history[$email][$ip] ?? ''; + if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) { + $history[$email][$ip] = $lastSeen; + } + } + } catch (Throwable $e) { + } + + $result = []; + foreach ($history as $email => $ips) { + arsort($ips); + $entries = []; + foreach ($ips as $ip => $lastSeen) { + $entries[] = [ + 'ip' => $ip, + 'last_seen' => $lastSeen, + ]; + if (count($entries) >= 5) { + break; + } + } + $result[$email] = $entries; + } + return $result; + } + + private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int + { + $email = strtolower(trim($email)); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return 0; + } + try { + $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1"); + $sel->execute([':email' => $email]); + $row = $sel->fetch(PDO::FETCH_ASSOC); + if ($row) { + $customerId = (int)($row['id'] ?? 0); + $ordersCount = (int)($row['orders_count'] ?? 0); + $totalSpent = (float)($row['total_spent'] ?? 0); + $upd = $db->prepare(" + UPDATE ac_store_customers + SET last_order_ip = :ip, + last_order_user_agent = :ua, + last_seen_at = NOW(), + orders_count = :orders_count, + total_spent = :total_spent, + updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':ip' => $ip !== '' ? $ip : null, + ':ua' => $userAgent !== '' ? $userAgent : null, + ':orders_count' => $ordersCount + 1, + ':total_spent' => $totalSpent + $orderTotal, + ':id' => $customerId, + ]); + return $customerId; + } + + $ins = $db->prepare(" + INSERT INTO ac_store_customers + (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at) + VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW()) + "); + $ins->execute([ + ':email' => $email, + ':ip' => $ip !== '' ? $ip : null, + ':ua' => $userAgent !== '' ? $userAgent : null, + ':total_spent' => $orderTotal, + ]); + return (int)$db->lastInsertId(); + } catch (Throwable $e) { + return 0; + } + } + + private function bumpDiscountUsage(PDO $db, string $code): void + { + $code = strtoupper(trim($code)); + if ($code === '') { + return; + } + try { + $stmt = $db->prepare(" + UPDATE ac_store_discount_codes + SET used_count = used_count + 1, updated_at = NOW() + WHERE code = :code + "); + $stmt->execute([':code' => $code]); + } catch (Throwable $e) { + } + } + + private function isEnabledSetting($value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + $normalized = strtolower(trim((string)$value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } + + private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array + { + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $url = $base . '/v1/oauth2/token'; + $headers = [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), + 'Content-Type: application/x-www-form-urlencoded', + 'Accept: application/json', + ]; + $body = 'grant_type=client_credentials'; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'timeout' => 15, + 'ignore_errors' => true, + ], + ]); + $response = @file_get_contents($url, false, $context); + if ($response === false) { + return ['ok' => false, 'error' => 'PayPal test failed: network error']; + } + $statusLine = ''; + if (!empty($http_response_header[0])) { + $statusLine = (string)$http_response_header[0]; + } + preg_match('/\s(\d{3})\s/', $statusLine, $m); + $httpCode = isset($m[1]) ? (int)$m[1] : 0; + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + private function paypalCreateOrder( + string $clientId, + string $secret, + bool $sandbox, + string $currency, + float $total, + string $orderNo, + string $returnUrl, + string $cancelUrl + ): array { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $payload = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'reference_id' => $orderNo, + 'description' => 'AudioCore order ' . $orderNo, + 'custom_id' => $orderNo, + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($total, 2, '.', ''), + ], + ]], + 'application_context' => [ + 'return_url' => $returnUrl, + 'cancel_url' => $cancelUrl, + 'shipping_preference' => 'NO_SHIPPING', + 'user_action' => 'PAY_NOW', + ], + ]; + $res = $this->paypalJsonRequest( + $base . '/v2/checkout/orders', + 'POST', + $payload, + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $orderId = (string)($body['id'] ?? ''); + $approvalUrl = ''; + foreach ((array)($body['links'] ?? []) as $link) { + if ((string)($link['rel'] ?? '') === 'approve') { + $approvalUrl = (string)($link['href'] ?? ''); + break; + } + } + if ($orderId === '' || $approvalUrl === '') { + return ['ok' => false, 'error' => 'PayPal create order response incomplete']; + } + return [ + 'ok' => true, + 'order_id' => $orderId, + 'approval_url' => $approvalUrl, + ]; + } + + private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array + { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $res = $this->paypalJsonRequest( + $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture', + 'POST', + new \stdClass(), + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $status = (string)($body['status'] ?? ''); + if ($status !== 'COMPLETED') { + return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')]; + } + $captureId = ''; + $purchaseUnits = (array)($body['purchase_units'] ?? []); + if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) { + $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id']; + } + return ['ok' => true, 'capture_id' => $captureId]; + } + + private function paypalRefundCapture( + string $clientId, + string $secret, + bool $sandbox, + string $captureId, + string $currency, + float $total + ): array { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $payload = [ + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($total, 2, '.', ''), + ], + ]; + $res = $this->paypalJsonRequest( + $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund', + 'POST', + $payload, + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $status = strtoupper((string)($body['status'] ?? '')); + if (!in_array($status, ['COMPLETED', 'PENDING'], true)) { + return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')]; + } + return ['ok' => true]; + } + + private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array + { + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $url = $base . '/v1/oauth2/token'; + $headers = [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), + 'Content-Type: application/x-www-form-urlencoded', + 'Accept: application/json', + ]; + $body = 'grant_type=client_credentials'; + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true, 'access_token' => (string)$decoded['access_token']]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; + } + + private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array + { + if (!function_exists('curl_init')) { + return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; + } + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + 'Accept: application/json', + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 25); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300) { + return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []]; + } + $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + private function orderItemsForEmail(PDO $db, int $orderId): array + { + try { + $stmt = $db->prepare(" + SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency + FROM ac_store_order_items + WHERE order_id = :order_id + ORDER BY id ASC + "); + $stmt->execute([':order_id' => $orderId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } catch (Throwable $e) { + return []; + } + } +} diff --git a/plugins/store/customers.php b/plugins/store/customers.php new file mode 100644 index 0000000..cb13d2e --- /dev/null +++ b/plugins/store/customers.php @@ -0,0 +1,43 @@ + +
    +
    Store
    +
    +
    +

    Customers

    +

    Registered and purchasing customers.

    +
    + Back +
    +
    + Overview + Settings + Orders + Customers +
    + + +
    No customers yet.
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    Store
    +
    +
    +

    Store

    +

    Commerce layer for releases/tracks.

    +
    +
    + Settings + Orders + Customers +
    +
    +
    + Overview + Settings + Orders + Customers +
    + + +
    +
    +
    Store tables not initialized
    +
    Create store tables before configuring products and checkout.
    +
    +
    + +
    +
    + + +
    +
    Private download root
    +
    + +
    +
    + +
    +
    +
    + +
    +
    Store
    +
    +
    +

    Orders

    +

    Order queue and payment status.

    +
    + Back +
    +
    + Overview + Settings + Orders + Customers +
    + + +
    No orders yet.
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + +
    +query("SELECT MAX(updated_at) FROM ac_store_orders WHERE status = 'paid'")->fetchColumn() ?? ''); + $latestCache = (string)($db->query("SELECT MAX(updated_at) FROM ac_store_sales_chart_cache")->fetchColumn() ?? ''); + if ($latestPaid !== '' && ($latestCache === '' || strcmp($latestPaid, $latestCache) > 0)) { + (new StoreController())->rebuildSalesChartCache(); + } + } catch (\Throwable $e) { + } + + try { + $stmt = $db->prepare(" + SELECT item_key, item_label AS title, units, revenue + FROM ac_store_sales_chart_cache + WHERE chart_scope = :scope + AND chart_window = :window + ORDER BY rank_no ASC + LIMIT :limit + "); + $stmt->bindValue(':scope', $scope, \PDO::PARAM_STR); + $stmt->bindValue(':window', $window, \PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } catch (\Throwable $e) { + $rows = []; + } + + if (!$rows && $scope === 'tracks') { + try { + $stmt = $db->prepare(" + SELECT item_key, item_label AS title, units, revenue + FROM ac_store_sales_chart_cache + WHERE chart_scope = 'releases' + AND chart_window = :window + ORDER BY rank_no ASC + LIMIT :limit + "); + $stmt->bindValue(':window', $window, \PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } catch (\Throwable $e) { + $rows = []; + } + } + + if (!$rows) { + return '
    No sales yet.
    '; + } + + $releaseIds = []; + $trackIds = []; + foreach ($rows as $row) { + $itemKey = trim((string)($row['item_key'] ?? '')); + if (preg_match('/^release:(\d+)$/', $itemKey, $m)) { + $releaseIds[] = (int)$m[1]; + } elseif (preg_match('/^track:(\d+)$/', $itemKey, $m)) { + $trackIds[] = (int)$m[1]; + } + } + $releaseIds = array_values(array_unique(array_filter($releaseIds))); + $trackIds = array_values(array_unique(array_filter($trackIds))); + + $releaseMap = []; + if ($releaseIds) { + try { + $in = implode(',', array_fill(0, count($releaseIds), '?')); + $stmt = $db->prepare(" + SELECT id, title, slug, cover_url, COALESCE(artist_name, '') AS artist_name + FROM ac_releases + WHERE id IN ({$in}) + "); + foreach ($releaseIds as $i => $rid) { + $stmt->bindValue($i + 1, $rid, \PDO::PARAM_INT); + } + $stmt->execute(); + $rels = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + foreach ($rels as $rel) { + $releaseMap['release:' . (int)$rel['id']] = $rel; + } + } catch (\Throwable $e) { + } + } + + if ($trackIds) { + try { + $in = implode(',', array_fill(0, count($trackIds), '?')); + $stmt = $db->prepare(" + SELECT t.id AS track_id, r.id, r.title, r.slug, r.cover_url, COALESCE(r.artist_name, '') AS artist_name + FROM ac_release_tracks t + JOIN ac_releases r ON r.id = t.release_id + WHERE t.id IN ({$in}) + "); + foreach ($trackIds as $i => $tid) { + $stmt->bindValue($i + 1, $tid, \PDO::PARAM_INT); + } + $stmt->execute(); + $rels = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + foreach ($rels as $rel) { + $releaseMap['track:' . (int)$rel['track_id']] = $rel; + } + } catch (\Throwable $e) { + } + } + + $list = ''; + $position = 1; + foreach ($rows as $row) { + $itemKey = trim((string)($row['item_key'] ?? '')); + $rel = $releaseMap[$itemKey] ?? null; + + $titleRaw = $rel ? (string)($rel['title'] ?? '') : (string)($row['title'] ?? ''); + $artistRaw = $rel ? trim((string)($rel['artist_name'] ?? '')) : ''; + $slugRaw = $rel ? trim((string)($rel['slug'] ?? '')) : ''; + $coverRaw = $rel ? trim((string)($rel['cover_url'] ?? '')) : ''; + + $title = htmlspecialchars($titleRaw !== '' ? $titleRaw : ((string)($row['title'] ?? 'Release')), ENT_QUOTES, 'UTF-8'); + $artist = htmlspecialchars($artistRaw, ENT_QUOTES, 'UTF-8'); + $href = $slugRaw !== '' ? '/release?slug=' . rawurlencode($slugRaw) : '#'; + + $thumbHtml = $coverRaw !== '' + ? '' + : '
    AC
    '; + + $copy = '' + . '' . $title . '' + . ($artist !== '' ? '' . $artist . '' : '') + . '' + . '' . $position . ''; + + $content = '' . $thumbHtml . '' . $copy; + + if ($href !== '#') { + $content = '' . $content . ''; + } + + $list .= '
  • ' . $content . '
  • '; + $position++; + } + + $heading = htmlspecialchars((string)($attrs['title'] ?? 'Top Sellers'), ENT_QUOTES, 'UTF-8'); + return '

    ' . $heading . '

      ' . $list . '
    '; +}); + +Shortcodes::register('login-link', static function (array $attrs = []): string { + $label = trim((string)($attrs['label'] ?? 'Login')); + if ($label === '') { + $label = 'Login'; + } + return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; +}); + +Shortcodes::register('account-link', static function (array $attrs = []): string { + $label = trim((string)($attrs['label'] ?? 'My Account')); + if ($label === '') { + $label = 'My Account'; + } + return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; +}); + +Shortcodes::register('checkout-link', static function (array $attrs = []): string { + $label = trim((string)($attrs['label'] ?? 'Checkout')); + if ($label === '') { + $label = 'Checkout'; + } + return '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; +}); + +Shortcodes::register('cart-link', static function (array $attrs = []): string { + $showCount = ((string)($attrs['show_count'] ?? '1')) !== '0'; + $showTotal = ((string)($attrs['show_total'] ?? '1')) !== '0'; + $label = trim((string)($attrs['label'] ?? 'Cart')); + if ($label === '') { + $label = 'Cart'; + } + + $count = 0; + $amount = 0.0; + $currency = strtoupper(trim((string)Settings::get('store_currency', 'GBP'))); + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + if (session_status() !== PHP_SESSION_ACTIVE) { + @session_start(); + } + $cart = is_array($_SESSION['ac_cart'] ?? null) ? $_SESSION['ac_cart'] : []; + foreach ($cart as $item) { + if (!is_array($item)) { + continue; + } + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $count += $qty; + $amount += ($price * $qty); + } + + $parts = [htmlspecialchars($label, ENT_QUOTES, 'UTF-8')]; + if ($showCount) { + $parts[] = '' . $count . ''; + } + if ($showTotal) { + $parts[] = '' . htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') . ' ' . number_format($amount, 2) . ''; + } + + return '' . implode(' ', $parts) . ''; +}); + +return function (Router $router): void { + $controller = new StoreController(); + $router->get('/cart', [$controller, 'cartIndex']); + $router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']); + $router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']); + $router->get('/checkout', [$controller, 'checkoutIndex']); + $router->get('/account', [$controller, 'accountIndex']); + $router->post('/account/request-login', [$controller, 'accountRequestLogin']); + $router->get('/account/login', [$controller, 'accountLogin']); + $router->get('/account/logout', [$controller, 'accountLogout']); + $router->post('/checkout/place', [$controller, 'checkoutPlace']); + $router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']); + $router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']); + $router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']); + $router->get('/store/download', [$controller, 'download']); + $router->post('/cart/remove', [$controller, 'cartRemove']); + $router->get('/store/sales-chart/rebuild', [$controller, 'salesChartCron']); + $router->get('/admin/store', [$controller, 'adminIndex']); + $router->post('/admin/store/install', [$controller, 'adminInstall']); + $router->get('/admin/store/settings', [$controller, 'adminSettings']); + $router->post('/admin/store/settings', [$controller, 'adminSaveSettings']); + $router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']); + $router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']); + $router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']); + $router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']); + $router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']); + $router->get('/admin/store/customers', [$controller, 'adminCustomers']); + $router->get('/admin/store/orders', [$controller, 'adminOrders']); + $router->post('/admin/store/orders/create', [$controller, 'adminOrderCreate']); + $router->post('/admin/store/orders/status', [$controller, 'adminOrderStatus']); + $router->post('/admin/store/orders/refund', [$controller, 'adminOrderRefund']); + $router->post('/admin/store/orders/delete', [$controller, 'adminOrderDelete']); + $router->get('/admin/store/order', [$controller, 'adminOrderView']); + $router->post('/store/cart/add', [$controller, 'cartAdd']); +}; diff --git a/plugins/store/settings.php b/plugins/store/settings.php new file mode 100644 index 0000000..b5bf96b --- /dev/null +++ b/plugins/store/settings.php @@ -0,0 +1,90 @@ + +
    +
    Store
    +
    +
    +

    Store Settings

    +

    Payment, private downloads, and order defaults.

    +
    + Back +
    +
    + Overview + Settings + Orders + Customers +
    + + +
    + + +
    Settings saved.
    + + +
    +
    +
    Currency
    + + +
    Private Download Root (outside public_html)
    + +
    + +
    + +
    +
    +
    Download Limit
    + +
    +
    +
    Expiry Days
    + +
    +
    +
    + +
    +
    Payment Provider (Stripe)
    + + +
    Stripe Public Key
    + +
    Stripe Secret Key
    + +
    + +
    +
    Payment Provider (PayPal)
    + +
    PayPal Client ID
    + +
    PayPal Secret
    + +
    + +
    + +
    +
    +
    + +
    +
    Store
    +
    +
    +

    Customers

    +

    Customer activity, value, and latest order access.

    +
    + Back +
    + + + + + + +
    No customers yet.
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    CustomerOrdersRevenueLatest OrderLast Seen
    +
    + +
    + + + + +
    + +
    + 0): ?> + + + + + No orders + +
    +
    + +
    + + + +
    +
    Store
    +
    +
    +

    Store

    +

    Commerce layer for releases/tracks.

    +
    +
    + Settings + Orders + Customers +
    +
    +
    + Overview + Settings + Orders + Customers +
    + + +
    +
    +
    Store tables not initialized
    +
    Create store tables before configuring products and checkout.
    +
    +
    + +
    +
    + + +
    +
    Private download root
    +
    + +
    +
    + +
    +
    + +
    +
    +
    Total Orders
    +
    +
    Paid:
    +
    +
    +
    Revenue
    +
    +
    +
    +
    Total Customers
    +
    +
    +
    + +
    +
    +
    Last 5 purchases
    + +
    No orders yet.
    + +
    + +
    +
    + + +
    +
    + + +
    + +
    + +
    + +
    + +
    +
    Top 5 new customers
    + +
    No customers yet.
    + +
    + +
    +
    + + + + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    Store
    +
    +
    +

    Order Detail

    +

    Full order breakdown, downloads, and IP history.

    +
    + Back to Orders +
    +
    + Overview + Settings + Orders + Customers +
    + +
    +
    +
    +
    Order Number
    +
    +
    +
    +
    Status
    +
    +
    +
    +
    Total
    +
    +
    +
    +
    Customer Email
    +
    +
    +
    +
    Order IP
    +
    +
    +
    +
    Created
    +
    +
    +
    +
    + +
    +
    Items
    + +
    No items on this order.
    + +
    + + 0, 'ips' => []]; + $ips = is_array($downloadMeta['ips'] ?? null) ? $downloadMeta['ips'] : []; + ?> +
    +
    +
    +
    +
    + # + | Qty + | +
    + +
    File:
    + +
    +
    +
    Downloads /
    +
    + Used +
    +
    +
    + +
    + + + +
    + +
    + +
    + +
    + +
    +
    Download Activity
    + +
    No download activity yet.
    + +
    + +
    +
    +
    +
    Item # |
    +
    +
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    Store
    +
    +
    +

    Orders

    +

    Manage order status, refunds, and clean-up.

    +
    + Back +
    + + + + +
    Order update saved.
    + + +
    + + +
    +
    +
    Add Manual Order
    +
    + + + + + +
    +
    + + +
    + + +
    No orders yet.
    + +
    + + +
    +
    + + + +
    + + + +
    +
    + +
    + + +
    + +
    + +
    +
    + + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + +
    + +
    + + + + + + +
    +
    Store
    +
    +
    +

    Store Settings

    +

    Configure defaults, payments, and transactional emails.

    +
    + Back +
    + +
    + General + Payments + Emails + Discounts + Sales Chart +
    + + +
    + + +
    Settings saved.
    + + +
    PayPal credentials are valid.
    + + + +
    + +
    +
    Currency
    + + +
    Private Download Root (outside public_html)
    + +
    + +
    + +
    +
    +
    Download Limit
    + +
    +
    +
    Expiry Days
    + +
    +
    + +
    Order Number Prefix
    + +
    + +
    + +
    +
    + +
    + +
    +
    Payment Mode
    + + +
    + +
    +
    PayPal
    + + +
    PayPal Client ID
    + +
    PayPal Secret
    + + +
    + + + +
    +
    + +
    + +
    + +
    +
    Order Email Template
    +
    Email Logo URL
    + + +
    Subject
    + + +
    HTML Body
    + + +
    + Placeholders: {{site_name}}, {{order_no}}, {{customer_email}}, {{currency}}, {{total}}, {{status}}, {{logo_url}}, {{logo_html}}, {{items_html}}, {{download_links_html}} +
    + +
    +
    Send Test Email To
    + +
    + + +
    +
    +
    +
    + +
    +
    Create Discount Code
    +
    +
    +
    +
    Code
    + +
    +
    +
    Discount Type
    + +
    +
    +
    Value
    + +
    +
    +
    Max Uses
    + +
    +
    +
    Expires At
    + +
    +
    +
    + + +
    +
    +
    Max uses: 0 means unlimited. Leave expiry blank for no expiry.
    +
    + +
    +
    Existing Codes
    + +
    No discount codes yet.
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    CodeTypeValueUsageExpiresStatusAction
    / +
    + + +
    +
    +
    + +
    + +
    + +
    +
    Sales Chart Defaults
    +
    +
    +
    Default Type
    + +
    +
    +
    Default Window
    + +
    +
    +
    Default Limit
    + +
    +
    +
    +
    +
    Latest Window Hours
    + +
    +
    +
    Cron Refresh Minutes
    + +
    +
    +
    Cron Key
    +
    + + +
    +
    Cron URL (dynamic)
    + +
    Crontab Line
    + +
    + + +
    +
    +
    + +
    +
    +
    Current Chart Snapshot
    +
    + Last rebuild: +
    +
    + +
    No chart rows yet. Run rebuild once.
    + +
    + + + + + + + + + + + + + + + + + + + + + +
    #ItemUnitsRevenueWindow
    + -> +
    +
    + +
    + +
    + +
    +
    Store
    + + + + + + + + + + + + + + + + + +
    + + + 0, 'subtotal' => 0.0, 'discount_amount' => 0.0, 'amount' => 0.0, 'currency' => 'GBP', 'discount_code' => '']; +$discountCode = (string)($totals['discount_code'] ?? ''); +ob_start(); +?> +
    + Continue shopping +

    Your Cart

    + +
    + Your basket is empty. +
    + +
    + + +
    +
    + + + +
    AC
    + +
    +
    +
    +
    x
    +
    +
    +
    + + + +
    +
    + +
    +
    +
    +
    item(s)
    +
    +
    Subtotal:
    + 0): ?> +
    Discount (): -
    + +
    +
    +
    +
    +
    + + Applied discount + +
    + + +
    + +
    + +
    +
    + + + +
    +
    +
    +
    +
    + + Checkout +
    + +
    + + + +
    +
    Store
    +

    Checkout

    + +
    +
    Order complete
    + +
    Order:
    + +
    +
    +
    Your Downloads
    + +
    + + + + + Download + + +
    + +

    + +

    + +
    + + +
    + +
    + + +
    + Your cart is empty. +
    +
    Browse releases
    + +
    +
    +
    Order Summary
    +
    + + +
    +
    +
    x
    +
    +
    + +
    +
    + Subtotal + +
    + 0): ?> +
    + Discount () + - +
    + +
    + Order total + +
    +
    + +
    +
    Buyer Details
    +
    + + + +
    +
    Terms
    +

    + Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order. + Files are limited to downloads and expire after days. +

    + +
    + + +
    +
    +
    + +
    + +view = new View(__DIR__ . '/views'); + } + + public function contactForm(): Response + { + $supportTypes = $this->supportTypes(); + return new Response($this->view->render('site/contact.php', [ + 'title' => 'Contact', + 'error' => (string)($_GET['error'] ?? ''), + 'ok' => (string)($_GET['ok'] ?? ''), + 'support_types' => $supportTypes, + ])); + } + + public function contactSubmit(): Response + { + $name = trim((string)($_POST['name'] ?? '')); + $email = trim((string)($_POST['email'] ?? '')); + $subject = trim((string)($_POST['subject'] ?? '')); + $message = trim((string)($_POST['message'] ?? '')); + $supportType = trim((string)($_POST['support_type'] ?? 'general')); + $supportTypes = $this->supportTypes(); + $supportTypesMap = []; + foreach ($supportTypes as $type) { + $supportTypesMap[(string)$type['key']] = $type; + } + if (!isset($supportTypesMap[$supportType])) { + $supportType = (string)($supportTypes[0]['key'] ?? 'general'); + } + $typeMeta = $supportTypesMap[$supportType] ?? ['label' => 'General', 'fields' => []]; + $requiredFields = is_array($typeMeta['fields'] ?? null) ? $typeMeta['fields'] : []; + $extraValuesRaw = $_POST['support_extra'] ?? []; + $extraValues = []; + if (is_array($extraValuesRaw)) { + foreach ($extraValuesRaw as $key => $value) { + $safeKey = $this->slugifyKey((string)$key); + $extraValues[$safeKey] = trim((string)$value); + } + } + + if ($name === '' || $email === '' || $subject === '' || $message === '') { + return new Response('', 302, ['Location' => '/contact?error=Please+complete+all+fields']); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/contact?error=Please+enter+a+valid+email']); + } + foreach ($requiredFields as $requiredField) { + if (($extraValues[(string)$requiredField] ?? '') === '') { + return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]); + } + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/contact?error=Database+unavailable']); + } + if (!$this->tablesReady($db)) { + return new Response('', 302, ['Location' => '/contact?error=Support+module+not+initialized']); + } + + $publicId = $this->newTicketId(); + $ip = $this->clientIp(); + $typeLabel = (string)($typeMeta['label'] ?? 'General'); + $subjectWithType = '[' . $typeLabel . '] ' . $subject; + + $metaLines = []; + $metaLines[] = 'Type: ' . $typeLabel; + $fieldLabels = []; + foreach ((array)($typeMeta['field_labels'] ?? []) as $row) { + if (is_array($row) && isset($row['key'], $row['label'])) { + $fieldLabels[(string)$row['key']] = (string)$row['label']; + } + } + foreach ($requiredFields as $fieldKey) { + $fieldKey = (string)$fieldKey; + $val = trim((string)($extraValues[$fieldKey] ?? '')); + if ($val === '') { + continue; + } + $fieldLabel = $fieldLabels[$fieldKey] ?? $fieldKey; + $metaLines[] = $fieldLabel . ': ' . $val; + } + $fullBody = $message; + if ($metaLines) { + $fullBody .= "\n\n---\n" . implode("\n", $metaLines); + } + + try { + $stmt = $db->prepare(" + INSERT INTO ac_support_tickets (ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at) + VALUES (:ticket_no, :subject, 'open', :customer_name, :customer_email, :customer_ip, NOW()) + "); + $stmt->execute([ + ':ticket_no' => $publicId, + ':subject' => $subjectWithType, + ':customer_name' => $name, + ':customer_email' => $email, + ':customer_ip' => $ip, + ]); + $ticketId = (int)$db->lastInsertId(); + + $msgStmt = $db->prepare(" + INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) + VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'web') + "); + $msgStmt->execute([ + ':ticket_id' => $ticketId, + ':sender_name' => $name, + ':sender_email' => $email, + ':body_text' => $fullBody, + ]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/contact?error=Could+not+create+ticket']); + } + + return new Response('', 302, ['Location' => '/contact?ok=Ticket+created:+'.urlencode($publicId)]); + } + + public function adminIndex(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager', 'editor'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $db = Database::get(); + $tablesReady = false; + $tickets = []; + if ($db instanceof PDO) { + $tablesReady = $this->tablesReady($db); + if ($tablesReady) { + $q = trim((string)($_GET['q'] ?? '')); + $where = ''; + $params = []; + if ($q !== '') { + $where = "WHERE ticket_no LIKE :q OR customer_email LIKE :q OR subject LIKE :q"; + $params[':q'] = '%' . $q . '%'; + } + $stmt = $db->prepare(" + SELECT id, ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at, created_at + FROM ac_support_tickets + {$where} + ORDER BY last_message_at DESC, id DESC + LIMIT 100 + "); + $stmt->execute($params); + $tickets = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + } + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Support', + 'tables_ready' => $tablesReady, + 'tickets' => $tickets, + 'q' => (string)($_GET['q'] ?? ''), + ])); + } + + public function adminTicket(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager', 'editor'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $id = (int)($_GET['id'] ?? 0); + $db = Database::get(); + if (!$db instanceof PDO || $id <= 0) { + return new Response('', 302, ['Location' => '/admin/support']); + } + + $ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1"); + $ticketStmt->execute([':id' => $id]); + $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$ticket) { + return new Response('', 302, ['Location' => '/admin/support']); + } + + $messagesStmt = $db->prepare("SELECT * FROM ac_support_messages WHERE ticket_id = :id ORDER BY id ASC"); + $messagesStmt->execute([':id' => $id]); + $messages = $messagesStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + return new Response($this->view->render('admin/ticket.php', [ + 'title' => 'Ticket ' . (string)($ticket['ticket_no'] ?? ''), + 'ticket' => $ticket, + 'messages' => $messages, + 'saved' => (string)($_GET['saved'] ?? ''), + 'error' => (string)($_GET['error'] ?? ''), + ])); + } + + public function adminReply(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager', 'editor'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $ticketId = (int)($_POST['ticket_id'] ?? 0); + $body = trim((string)($_POST['body'] ?? '')); + if ($ticketId <= 0 || $body === '') { + return new Response('', 302, ['Location' => '/admin/support']); + } + + $db = Database::get(); + if (!$db instanceof PDO) { + return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&error=Database+unavailable']); + } + + $ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1"); + $ticketStmt->execute([':id' => $ticketId]); + $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$ticket) { + return new Response('', 302, ['Location' => '/admin/support']); + } + + $stmt = $db->prepare(" + INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) + VALUES (:ticket_id, 'admin', :sender_name, :sender_email, :body_text, 'admin') + "); + $stmt->execute([ + ':ticket_id' => $ticketId, + ':sender_name' => Auth::name(), + ':sender_email' => Settings::get('smtp_from_email', ''), + ':body_text' => $body, + ]); + $db->prepare("UPDATE ac_support_tickets SET last_message_at = NOW(), status = 'pending' WHERE id = :id")->execute([':id' => $ticketId]); + + $email = (string)($ticket['customer_email'] ?? ''); + if ($email !== '') { + $subject = '[AC-TICKET-' . (string)($ticket['ticket_no'] ?? $ticketId) . '] ' . (string)($ticket['subject'] ?? 'Support reply'); + $safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8')); + $html = '

    Hello ' . htmlspecialchars((string)($ticket['customer_name'] ?? 'there'), ENT_QUOTES, 'UTF-8') . ',

    ' + . '

    ' . $safeBody . '

    ' + . '

    Reply to this email to continue ticket ' . htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') . '.

    '; + $smtp = [ + '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', 'AudioCore Support'), + ]; + Mailer::send($email, $subject, $html, $smtp); + } + + return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']); + } + + public function adminSetStatus(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager', 'editor'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $ticketId = (int)($_POST['ticket_id'] ?? 0); + $status = trim((string)($_POST['status'] ?? 'open')); + if (!in_array($status, ['open', 'pending', 'closed'], true)) { + $status = 'open'; + } + $db = Database::get(); + if ($db instanceof PDO && $ticketId > 0) { + $stmt = $db->prepare("UPDATE ac_support_tickets SET status = :status WHERE id = :id"); + $stmt->execute([':status' => $status, ':id' => $ticketId]); + } + return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']); + } + + public function adminDeleteTicket(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $ticketId = (int)($_POST['ticket_id'] ?? 0); + $db = Database::get(); + if ($db instanceof PDO && $ticketId > 0) { + $stmt = $db->prepare("DELETE FROM ac_support_tickets WHERE id = :id"); + $stmt->execute([':id' => $ticketId]); + } + return new Response('', 302, ['Location' => '/admin/support']); + } + + public function adminSettings(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string)($_SERVER['HTTP_HOST'] ?? ''); + $baseUrl = $host !== '' ? ($scheme . '://' . $host) : ''; + $cronKey = $this->getCronKey(); + $cronUrl = $baseUrl !== '' ? ($baseUrl . '/support/imap-sync?key=' . urlencode($cronKey)) : '/support/imap-sync?key=' . urlencode($cronKey); + $cronCommand = "*/5 * * * * /usr/bin/curl -fsS '" . $cronUrl . "' >/dev/null 2>&1"; + + return new Response($this->view->render('admin/settings.php', [ + 'title' => 'Support Settings', + 'saved' => (string)($_GET['saved'] ?? ''), + 'regenerated' => (string)($_GET['regenerated'] ?? ''), + 'imap_test' => (string)($_GET['imap_test'] ?? ''), + 'imap_error' => (string)($_GET['imap_error'] ?? ''), + 'sync_result' => (string)($_GET['sync_result'] ?? ''), + 'imap_host' => Settings::get('support_imap_host', ''), + 'imap_port' => Settings::get('support_imap_port', '993'), + 'imap_encryption' => Settings::get('support_imap_encryption', 'ssl'), + 'imap_user' => Settings::get('support_imap_user', ''), + 'imap_pass' => Settings::get('support_imap_pass', ''), + 'imap_folder' => Settings::get('support_imap_folder', 'INBOX'), + 'support_from_email' => Settings::get('support_from_email', Settings::get('smtp_from_email', '')), + 'support_ticket_prefix' => Settings::get('support_ticket_prefix', 'TCK'), + 'support_type_rows' => $this->supportTypes(), + 'cron_command' => $cronCommand, + 'cron_key' => $cronKey, + ])); + } + + public function adminSaveSettings(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + Settings::set('support_imap_host', trim((string)($_POST['support_imap_host'] ?? ''))); + Settings::set('support_imap_port', trim((string)($_POST['support_imap_port'] ?? '993'))); + Settings::set('support_imap_encryption', trim((string)($_POST['support_imap_encryption'] ?? 'ssl'))); + Settings::set('support_imap_user', trim((string)($_POST['support_imap_user'] ?? ''))); + Settings::set('support_imap_pass', trim((string)($_POST['support_imap_pass'] ?? ''))); + Settings::set('support_imap_folder', trim((string)($_POST['support_imap_folder'] ?? 'INBOX'))); + Settings::set('support_from_email', trim((string)($_POST['support_from_email'] ?? ''))); + $ticketPrefix = $this->slugifyTicketPrefix((string)($_POST['support_ticket_prefix'] ?? 'TCK')); + Settings::set('support_ticket_prefix', $ticketPrefix); + Settings::set('support_cron_key', trim((string)($_POST['support_cron_key'] ?? $this->getCronKey()))); + $titles = $_POST['support_type_title'] ?? []; + $optionLabelsMap = $_POST['support_type_option_labels'] ?? []; + $optionKeysMap = $_POST['support_type_option_keys'] ?? []; + $rows = []; + if (is_array($titles)) { + foreach ($titles as $idx => $titleRaw) { + $title = trim((string)$titleRaw); + if ($title === '') { + continue; + } + $key = $this->slugifyKey($title); + $selected = []; + $fieldLabels = []; + $usedKeys = []; + $labels = []; + $keys = []; + if (is_array($optionLabelsMap) && isset($optionLabelsMap[$idx]) && is_array($optionLabelsMap[$idx])) { + $labels = $optionLabelsMap[$idx]; + } + if (is_array($optionKeysMap) && isset($optionKeysMap[$idx]) && is_array($optionKeysMap[$idx])) { + $keys = $optionKeysMap[$idx]; + } + foreach ($labels as $optIndex => $labelRaw) { + $safeLabel = trim((string)$labelRaw); + if ($safeLabel === '') { + continue; + } + $preferredKey = ''; + if (isset($keys[$optIndex])) { + $preferredKey = $this->slugifyKey((string)$keys[$optIndex]); + } + if ($preferredKey === '') { + $preferredKey = $this->slugifyKey($safeLabel); + } + $safeKey = $preferredKey; + $suffix = 2; + while (isset($usedKeys[$safeKey])) { + $safeKey = $preferredKey . '_' . $suffix; + $suffix++; + } + $usedKeys[$safeKey] = true; + $selected[] = $safeKey; + $fieldLabels[] = ['key' => $safeKey, 'label' => $safeLabel]; + } + $rows[] = [ + 'key' => $key, + 'label' => $title, + 'fields' => $selected, + 'field_labels' => $fieldLabels, + ]; + } + } + if (!$rows) { + $rows = $this->supportTypesFromConfig($this->defaultSupportTypesConfig()); + } + Settings::set('support_types_config', json_encode($rows, JSON_UNESCAPED_SLASHES)); + return new Response('', 302, ['Location' => '/admin/support/settings?saved=1']); + } + + public function adminRunImapSync(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $result = $this->runImapSync(); + $status = $result['ok'] ? 'ok' : ('error:' . $result['error']); + return new Response('', 302, ['Location' => '/admin/support/settings?sync_result=' . urlencode($status)]); + } + + public function adminRegenerateCronKey(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $newKey = bin2hex(random_bytes(16)); + Settings::set('support_cron_key', $newKey); + return new Response('', 302, ['Location' => '/admin/support/settings?regenerated=1']); + } + + public function cronImapSync(): Response + { + $key = trim((string)($_GET['key'] ?? '')); + if ($key === '' || !hash_equals($this->getCronKey(), $key)) { + return new Response('Unauthorized', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + $result = $this->runImapSync(); + $body = $result['ok'] + ? ('OK imported=' . (string)$result['imported'] . ' scanned=' . (string)$result['scanned']) + : ('ERROR ' . (string)$result['error']); + return new Response($body, $result['ok'] ? 200 : 500, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + public function adminTestImap(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + if (!function_exists('imap_open')) { + return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('PHP IMAP extension is not enabled on server')]); + } + + $host = trim((string)($_POST['support_imap_host'] ?? '')); + $port = (int)trim((string)($_POST['support_imap_port'] ?? '993')); + $enc = strtolower(trim((string)($_POST['support_imap_encryption'] ?? 'ssl'))); + $user = trim((string)($_POST['support_imap_user'] ?? '')); + $pass = (string)($_POST['support_imap_pass'] ?? ''); + $folder = trim((string)($_POST['support_imap_folder'] ?? 'INBOX')); + + if ($host === '' || $port <= 0 || $user === '' || $pass === '') { + return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('Host, port, username and password are required')]); + } + + if (defined('IMAP_OPENTIMEOUT')) { + @imap_timeout(IMAP_OPENTIMEOUT, 12); + } + if (defined('IMAP_READTIMEOUT')) { + @imap_timeout(IMAP_READTIMEOUT, 12); + } + + $imapFlags = '/imap'; + if ($enc === 'ssl') { + $imapFlags .= '/ssl'; + } elseif ($enc === 'tls') { + $imapFlags .= '/tls'; + } else { + $imapFlags .= '/notls'; + } + $mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder; + $mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder; + + $conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1); + if (!$conn) { + // Many hosts use self-signed/chain-issue certs; retry with novalidate-cert. + $conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1); + } + if (!$conn) { + $err = (string)imap_last_error(); + if ($err === '') { + $err = 'Connection failed'; + } + return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode($err)]); + } + + @imap_close($conn); + return new Response('', 302, ['Location' => '/admin/support/settings?imap_test=ok']); + } + + public function adminInstall(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + $db = Database::get(); + if ($db instanceof PDO) { + $this->createTables($db); + } + return new Response('', 302, ['Location' => '/admin/support']); + } + + private function tablesReady(PDO $db): bool + { + try { + $ticketCheck = $db->query("SHOW TABLES LIKE 'ac_support_tickets'"); + $msgCheck = $db->query("SHOW TABLES LIKE 'ac_support_messages'"); + return (bool)($ticketCheck && $ticketCheck->fetch(PDO::FETCH_ASSOC)) + && (bool)($msgCheck && $msgCheck->fetch(PDO::FETCH_ASSOC)); + } catch (Throwable $e) { + return false; + } + } + + private function createTables(PDO $db): void + { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_support_tickets ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_no VARCHAR(32) NOT NULL UNIQUE, + subject VARCHAR(255) NOT NULL, + status ENUM('open','pending','closed') NOT NULL DEFAULT 'open', + customer_name VARCHAR(160) NULL, + customer_email VARCHAR(190) NOT NULL, + customer_ip VARCHAR(45) NULL, + last_message_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_support_messages ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_id INT UNSIGNED NOT NULL, + sender_type ENUM('customer','admin') NOT NULL, + sender_name VARCHAR(160) NULL, + sender_email VARCHAR(190) NULL, + body_text MEDIUMTEXT NOT NULL, + source ENUM('web','email','admin') NOT NULL DEFAULT 'web', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_ticket_id (ticket_id), + CONSTRAINT fk_support_ticket FOREIGN KEY (ticket_id) REFERENCES ac_support_tickets(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_support_inbound_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + message_uid VARCHAR(190) NOT NULL UNIQUE, + ticket_id INT UNSIGNED NULL, + processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + /** + * @return array{ok:bool,error:string,imported:int,scanned:int} + */ + private function runImapSync(): array + { + if (!function_exists('imap_open')) { + return ['ok' => false, 'error' => 'IMAP extension missing', 'imported' => 0, 'scanned' => 0]; + } + $db = Database::get(); + if (!$db instanceof PDO || !$this->tablesReady($db)) { + return ['ok' => false, 'error' => 'Support tables not ready', 'imported' => 0, 'scanned' => 0]; + } + + $host = Settings::get('support_imap_host', ''); + $port = (int)Settings::get('support_imap_port', '993'); + $enc = strtolower(Settings::get('support_imap_encryption', 'ssl')); + $user = Settings::get('support_imap_user', ''); + $pass = Settings::get('support_imap_pass', ''); + $folder = Settings::get('support_imap_folder', 'INBOX'); + if ($host === '' || $port <= 0 || $user === '' || $pass === '') { + return ['ok' => false, 'error' => 'IMAP settings incomplete', 'imported' => 0, 'scanned' => 0]; + } + + $imapFlags = '/imap'; + if ($enc === 'ssl') { + $imapFlags .= '/ssl'; + } elseif ($enc === 'tls') { + $imapFlags .= '/tls'; + } else { + $imapFlags .= '/notls'; + } + $mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder; + $mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder; + + $conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1); + if (!$conn) { + $conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1); + } + if (!$conn) { + $err = (string)imap_last_error(); + return ['ok' => false, 'error' => ($err !== '' ? $err : 'IMAP connection failed'), 'imported' => 0, 'scanned' => 0]; + } + + $msgNos = @imap_search($conn, 'UNSEEN') ?: []; + if (!$msgNos) { + $msgNos = @imap_search($conn, 'ALL') ?: []; + if (count($msgNos) > 100) { + $msgNos = array_slice($msgNos, -100); + } + } + + $imported = 0; + $scanned = 0; + $supportFrom = strtolower(Settings::get('support_from_email', Settings::get('smtp_from_email', ''))); + + foreach ($msgNos as $msgNoRaw) { + $msgNo = (int)$msgNoRaw; + if ($msgNo <= 0) { + continue; + } + $scanned++; + $overview = @imap_fetch_overview($conn, (string)$msgNo, 0); + if (!$overview || empty($overview[0])) { + continue; + } + $ov = $overview[0]; + $subject = isset($ov->subject) ? imap_utf8((string)$ov->subject) : ''; + $fromRaw = isset($ov->from) ? (string)$ov->from : ''; + $messageId = isset($ov->message_id) ? trim((string)$ov->message_id) : ''; + if ($messageId === '') { + $uid = @imap_uid($conn, $msgNo); + $messageId = 'uid-' . (string)$uid; + } + + $existsStmt = $db->prepare("SELECT id FROM ac_support_inbound_log WHERE message_uid = :uid LIMIT 1"); + $existsStmt->execute([':uid' => $messageId]); + if ($existsStmt->fetch(PDO::FETCH_ASSOC)) { + continue; + } + + $token = $this->extractTicketToken($subject); + if ($token === '') { + $this->logInbound($db, $messageId, null); + continue; + } + + $ticketStmt = $db->prepare("SELECT id FROM ac_support_tickets WHERE ticket_no = :ticket_no LIMIT 1"); + $ticketStmt->execute([':ticket_no' => $token]); + $ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC); + if (!$ticket) { + $this->logInbound($db, $messageId, null); + continue; + } + $ticketId = (int)$ticket['id']; + + $fromEmail = $this->extractEmailFromHeader($fromRaw); + if ($fromEmail !== '' && $supportFrom !== '' && strtolower($fromEmail) === $supportFrom) { + $this->logInbound($db, $messageId, $ticketId); + continue; + } + + $body = $this->fetchMessageBody($conn, $msgNo); + if ($body === '') { + $this->logInbound($db, $messageId, $ticketId); + continue; + } + + $insertMsg = $db->prepare(" + INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source) + VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'email') + "); + $insertMsg->execute([ + ':ticket_id' => $ticketId, + ':sender_name' => '', + ':sender_email' => $fromEmail !== '' ? $fromEmail : $fromRaw, + ':body_text' => $body, + ]); + $db->prepare("UPDATE ac_support_tickets SET status = 'open', last_message_at = NOW() WHERE id = :id")->execute([':id' => $ticketId]); + $this->logInbound($db, $messageId, $ticketId); + $imported++; + } + + @imap_close($conn); + return ['ok' => true, 'error' => '', 'imported' => $imported, 'scanned' => $scanned]; + } + + private function logInbound(PDO $db, string $uid, ?int $ticketId): void + { + $stmt = $db->prepare("INSERT INTO ac_support_inbound_log (message_uid, ticket_id) VALUES (:uid, :ticket_id)"); + $stmt->execute([ + ':uid' => $uid, + ':ticket_id' => $ticketId, + ]); + } + + private function extractTicketToken(string $subject): string + { + if (preg_match('/\\[AC-TICKET-([A-Z0-9\\-]+)\\]/i', $subject, $m)) { + return strtoupper(trim((string)$m[1])); + } + return ''; + } + + private function extractEmailFromHeader(string $from): string + { + if (preg_match('/<([^>]+)>/', $from, $m)) { + return trim((string)$m[1]); + } + return filter_var(trim($from), FILTER_VALIDATE_EMAIL) ? trim($from) : ''; + } + + private function fetchMessageBody($conn, int $msgNo): string + { + $structure = @imap_fetchstructure($conn, $msgNo); + if (!$structure) { + $raw = (string)@imap_body($conn, $msgNo); + return $this->sanitizeEmailBody($raw); + } + $body = ''; + if (isset($structure->parts) && is_array($structure->parts)) { + foreach ($structure->parts as $i => $part) { + $partNo = (string)($i + 1); + $isText = ((int)($part->type ?? -1) === 0); + $subtype = strtolower((string)($part->subtype ?? '')); + if ($isText && $subtype === 'plain') { + $body = (string)@imap_fetchbody($conn, $msgNo, $partNo); + $body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0)); + break; + } + } + if ($body === '') { + foreach ($structure->parts as $i => $part) { + $partNo = (string)($i + 1); + $isText = ((int)($part->type ?? -1) === 0); + if ($isText) { + $body = (string)@imap_fetchbody($conn, $msgNo, $partNo); + $body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0)); + break; + } + } + } + } + if ($body === '') { + $raw = (string)@imap_body($conn, $msgNo); + $body = $this->decodeImapBodyByEncoding($raw, (int)($structure->encoding ?? 0)); + } + return $this->sanitizeEmailBody($body); + } + + private function decodeImapBodyByEncoding(string $body, int $encoding): string + { + return match ($encoding) { + 3 => base64_decode($body, true) ?: $body, + 4 => quoted_printable_decode($body), + default => $body, + }; + } + + private function sanitizeEmailBody(string $body): string + { + $body = str_replace("\r\n", "\n", $body); + $body = preg_replace('/\nOn .*wrote:\n.*/s', '', $body) ?? $body; + $body = trim($body); + if (strlen($body) > 8000) { + $body = substr($body, 0, 8000); + } + return $body; + } + + private function getCronKey(): string + { + $key = trim(Settings::get('support_cron_key', '')); + if ($key === '') { + $key = bin2hex(random_bytes(16)); + Settings::set('support_cron_key', $key); + } + return $key; + } + + private function newTicketId(): string + { + $prefix = $this->slugifyTicketPrefix(Settings::get('support_ticket_prefix', 'TCK')); + return $prefix . '-' . date('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)); + } + + private function slugifyTicketPrefix(string $value): string + { + $value = strtoupper(trim($value)); + $value = preg_replace('/[^A-Z0-9]+/', '-', $value) ?? $value; + $value = trim($value, '-'); + if ($value === '') { + return 'TCK'; + } + return substr($value, 0, 16); + } + + private function defaultSupportTypesConfig(): string + { + return json_encode([ + ['key' => 'general', 'label' => 'General', 'fields' => [], 'field_labels' => []], + ['key' => 'order_issue', 'label' => 'Order Issue', 'fields' => ['order_no'], 'field_labels' => [['key' => 'order_no', 'label' => 'Order Number']]], + ['key' => 'billing', 'label' => 'Billing', 'fields' => ['billing_ref'], 'field_labels' => [['key' => 'billing_ref', 'label' => 'Billing Reference']]], + ['key' => 'technical', 'label' => 'Technical', 'fields' => ['page_url', 'browser_info'], 'field_labels' => [['key' => 'page_url', 'label' => 'Page URL'], ['key' => 'browser_info', 'label' => 'Browser / Device']]], + ['key' => 'dmca', 'label' => 'DMCA', 'fields' => ['infringing_url', 'rights_owner', 'proof_url'], 'field_labels' => [['key' => 'infringing_url', 'label' => 'Infringing URL'], ['key' => 'rights_owner', 'label' => 'Rights Owner'], ['key' => 'proof_url', 'label' => 'Proof URL']]], + ['key' => 'other', 'label' => 'Other', 'fields' => [], 'field_labels' => []], + ], JSON_UNESCAPED_SLASHES) ?: '[]'; + } + + /** + * @return array}> + */ + private function supportTypes(): array + { + $raw = Settings::get('support_types_config', $this->defaultSupportTypesConfig()); + return $this->supportTypesFromConfig($raw); + } + + /** + * @return array}> + */ + private function supportTypesFromConfig(string $raw): array + { + $types = []; + $decodedJson = json_decode($raw, true); + if (is_array($decodedJson)) { + foreach ($decodedJson as $row) { + if (!is_array($row)) { + continue; + } + $key = $this->slugifyKey((string)($row['key'] ?? $row['label'] ?? '')); + $label = trim((string)($row['label'] ?? '')); + $rawFields = is_array($row['fields'] ?? null) ? $row['fields'] : []; + $rawFieldLabels = is_array($row['field_labels'] ?? null) ? $row['field_labels'] : []; + if ($key === '' || $label === '') { + continue; + } + $fields = []; + foreach ($rawFields as $field) { + $field = trim((string)$field); + if ($field !== '') { + $fields[] = $field; + } + } + $fieldLabels = []; + foreach ($rawFieldLabels as $fr) { + if (!is_array($fr)) { + continue; + } + $fk = $this->slugifyKey((string)($fr['key'] ?? '')); + $fl = trim((string)($fr['label'] ?? '')); + if ($fk !== '' && $fl !== '') { + $fieldLabels[] = ['key' => $fk, 'label' => $fl]; + } + } + $types[] = [ + 'key' => $key, + 'label' => $label, + 'fields' => array_values(array_unique($fields)), + 'field_labels' => $fieldLabels, + ]; + } + } + if ($types) { + return $types; + } + + foreach (preg_split('/\r?\n/', $raw) as $line) { + $line = trim((string)$line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $parts = array_map('trim', explode('|', $line)); + $key = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($parts[0] ?? ''))); + $label = (string)($parts[1] ?? ''); + $fieldRaw = (string)($parts[2] ?? ''); + if ($key === '' || $label === '') { + continue; + } + $fields = []; + foreach (array_map('trim', explode(',', $fieldRaw)) as $field) { + if ($field !== '') { + $fields[] = $field; + } + } + $types[] = ['key' => $key, 'label' => $label, 'fields' => $fields]; + } + if (!$types) { + return [ + ['key' => 'general', 'label' => 'General', 'fields' => []], + ]; + } + return $types; + } + + private function slugifyKey(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9]+/', '_', $value) ?? $value; + $value = trim($value, '_'); + return $value !== '' ? substr($value, 0, 48) : 'type_' . substr(bin2hex(random_bytes(2)), 0, 4); + } + + private function clientIp(): string + { + $ip = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''); + if (strpos($ip, ',') !== false) { + $parts = explode(',', $ip); + $ip = trim((string)$parts[0]); + } + return substr($ip, 0, 45); + } +} diff --git a/plugins/support/plugin.json b/plugins/support/plugin.json new file mode 100644 index 0000000..da88026 --- /dev/null +++ b/plugins/support/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "Support", + "version": "0.1.0", + "description": "Contact form and support ticket system.", + "author": "AudioCore", + "admin_nav": { + "label": "Support", + "url": "/admin/support", + "roles": ["admin", "manager", "editor"], + "icon": "fa-solid fa-life-ring" + }, + "entry": "plugin.php", + "default_enabled": false +} diff --git a/plugins/support/plugin.php b/plugins/support/plugin.php new file mode 100644 index 0000000..6910c58 --- /dev/null +++ b/plugins/support/plugin.php @@ -0,0 +1,36 @@ +' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . ''; +}); + +return function (Router $router): void { + $controller = new SupportController(); + + $router->get('/contact', [$controller, 'contactForm']); + $router->post('/contact', [$controller, 'contactSubmit']); + + $router->get('/admin/support', [$controller, 'adminIndex']); + $router->post('/admin/support/install', [$controller, 'adminInstall']); + $router->get('/admin/support/settings', [$controller, 'adminSettings']); + $router->post('/admin/support/settings', [$controller, 'adminSaveSettings']); + $router->post('/admin/support/settings/regenerate-key', [$controller, 'adminRegenerateCronKey']); + $router->post('/admin/support/settings/test-imap', [$controller, 'adminTestImap']); + $router->post('/admin/support/settings/run-sync', [$controller, 'adminRunImapSync']); + $router->get('/support/imap-sync', [$controller, 'cronImapSync']); + $router->get('/admin/support/ticket', [$controller, 'adminTicket']); + $router->post('/admin/support/ticket/reply', [$controller, 'adminReply']); + $router->post('/admin/support/ticket/status', [$controller, 'adminSetStatus']); + $router->post('/admin/support/ticket/delete', [$controller, 'adminDeleteTicket']); +}; diff --git a/plugins/support/views/admin/index.php b/plugins/support/views/admin/index.php new file mode 100644 index 0000000..bf498e6 --- /dev/null +++ b/plugins/support/views/admin/index.php @@ -0,0 +1,61 @@ + +
    +
    Support
    +
    +
    +

    Tickets

    +

    Contact form submissions and support conversations.

    +
    + Settings +
    + + +
    +
    +
    Support tables not initialized
    +
    Create support tables before accepting contact tickets.
    +
    +
    + +
    +
    + +
    + + + Reset +
    + +
    + +
    No tickets yet.
    + + + +
    + +
    + +
    +
    Support
    +
    +
    +

    Support Settings

    +

    Configure ticket behavior, IMAP inbox sync, and request categories.

    +
    + Back +
    + +
    Settings saved.
    +
    IMAP connection successful.
    +
    + +
    + Sync result: +
    + + +
    + + + +
    + +
    +
    +
    +
    +
    Ticket Prefix
    + +
    Example: TCK-20260221-ABC123
    +
    +
    Cron job keys and commands are now managed from Admin > Cron Jobs.
    +
    +
    + +
    +
    +
    +
    +
    IMAP Host
    + +
    +
    +
    IMAP Port
    + +
    +
    +
    Encryption
    + +
    +
    + +
    +
    +
    IMAP Username
    + +
    +
    +
    IMAP Password
    + +
    +
    + +
    +
    +
    Inbox Folder
    + +
    +
    +
    Support From Email (optional)
    + +
    +
    +
    + + +
    +
    +
    + + + +
    + +
    +
    +
    + + + + + + + + +
    +
    Support
    +
    +
    +

    +

    +
    +
    +
    + + +
    + Back +
    +
    + + +
    Updated.
    + + +
    + + +
    +
    + · + · + +
    +
    + + + +
    +
    + +
    +
    Thread
    + +
    No messages yet.
    + +
    + + +
    +
    + · + +
    +
    +
    + +
    + +
    + +
    + +
    Reply
    + +
    + +
    +
    +
    + 'general', 'label' => 'General', 'fields' => []]]; +$supportTypesJson = json_encode($supportTypes, JSON_UNESCAPED_SLASHES) ?: '[]'; +ob_start(); +?> +
    +
    Support
    +

    Contact

    +

    Send us a message and we will open a support ticket.

    + + +
    + + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + +
    + + +
    +
    + + +
    + +
    + +
    +
    +
    + + + + +' + . '
    Maintenance Mode
    ' + . '

    ' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '

    ' + . '

    ' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . '

    '; + if ($buttonLabel !== '' && $buttonUrl !== '') { + $contentHtml .= '' + . htmlspecialchars($buttonLabel, ENT_QUOTES, 'UTF-8') + . ''; + } + $contentHtml .= ''; + } + + $maintenanceHtml = '' + . '' + . '' . htmlspecialchars($siteTitle, ENT_QUOTES, 'UTF-8') . '' + . '' . $contentHtml . ''; + + (new Core\Http\Response($maintenanceHtml, 503, ['Content-Type' => 'text/html; charset=utf-8']))->send(); + exit; +} + +$router = new Core\Http\Router(); +$router->get('/', function (): Core\Http\Response { + $db = Core\Services\Database::get(); + if ($db instanceof PDO) { + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE is_home = 1 AND is_published = 1 LIMIT 1"); + $stmt->execute(); + $page = $stmt->fetch(PDO::FETCH_ASSOC); + if ($page) { + $view = new Core\Views\View(__DIR__ . '/../modules/pages/views'); + return new Core\Http\Response($view->render('site/show.php', [ + 'title' => (string)$page['title'], + 'content_html' => Core\Services\Shortcodes::render((string)$page['content_html'], [ + 'page_slug' => 'home', + 'page_title' => (string)$page['title'], + ]), + ])); + } + } + $view = new Core\Views\View(__DIR__ . '/../views'); + return new Core\Http\Response($view->render('site/home.php', [ + 'title' => 'AudioCore V1.5', + ])); +}); +$router->registerModules(__DIR__ . '/../modules'); +Core\Services\Plugins::register($router); + +$response = $router->dispatch($_SERVER['REQUEST_URI'] ?? '/', $_SERVER['REQUEST_METHOD'] ?? 'GET'); +$response->send(); diff --git a/show.php b/show.php new file mode 100644 index 0000000..167ba35 --- /dev/null +++ b/show.php @@ -0,0 +1,250 @@ + +
    +
    Release
    + +

    Release not found

    +

    This release is unavailable.

    + +
    +
    +
    +

    +
    + +
    +
    Catalog
    +
    +
    + + +
    +
    Release Date
    +
    +
    + +
    + +
    + +
    + +
    + +
    + + + +
    AUDIOCORE
    + +
    +
    + + +
    +
    Credits
    +
    + +
    +
    + + + +
    +
    +
    Tracklist
    +
    Select a track to play sample
    +
    + +
    + + +
    + +
    +
    + + + () + +
    +
    + 0 ? '#' . (int)$track['track_no'] : 'Track' ?> + + + +
    +
    +
    + +
    +
    + +
    +
    + +
    + +
    + + + + + + + 'localhost', + 'database' => 'audi_ocore', + 'user' => 'audi_ocore', + 'pass' => 'qUZ^9*9UQxbDXnNx', + 'port' => 3306, +]; diff --git a/storage/logs/store_mail.log b/storage/logs/store_mail.log new file mode 100644 index 0000000..55d727c --- /dev/null +++ b/storage/logs/store_mail.log @@ -0,0 +1,3 @@ +[2026-02-20T18:52:27+00:00] mailtest-20260220185227-983 {"to":"johnathon.tuke@gmail.com","subject":"Your Downloads Are Ready","result":{"ok":false,"error":"SMTP AUTH LOGIN failed","debug":"S: 220 strawberry.munkeh.host ESMTP Postfix\nC: EHLO localhost\nS: 250-strawberry.munkeh.host\r\n250-PIPELINING\r\n250-SIZE 30720000\r\n250-ETRN\r\n250-STARTTLS\r\n250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 CHUNKING\nC: STARTTLS\nS: 220 2.0.0 Ready to start TLS\nC: EHLO localhost\nS: 250-strawberry.munkeh.host\r\n250-PIPELINING\r\n250-SIZE 30720000\r\n250-ETRN\r\n250-AUTH PLAIN\r\n250-AUTH=PLAIN\r\n250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 CHUNKING\nC: AUTH LOGIN\nS: 535 5.7.8 Error: authentication failed: Invalid authentication mechanism"}} +[2026-02-20T18:54:11+00:00] mailtest-20260220185411-339 {"to":"johnathon.tuke@gmail.com","subject":"Your Downloads Are Ready","result":{"ok":false,"error":"SMTP AUTH LOGIN failed","debug":"S: 220 strawberry.munkeh.host ESMTP Postfix\nC: EHLO localhost\nS: 250-strawberry.munkeh.host\r\n250-PIPELINING\r\n250-SIZE 30720000\r\n250-ETRN\r\n250-AUTH PLAIN\r\n250-AUTH=PLAIN\r\n250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 CHUNKING\nC: AUTH LOGIN\nS: 535 5.7.8 Error: authentication failed: Invalid authentication mechanism"}} +[2026-02-20T18:56:43+00:00] mailtest-20260220185643-636 {"to":"johnathon.tuke@gmail.com","subject":"Your Downloads Are Ready","result":{"ok":true,"error":"","debug":"S: 220 strawberry.munkeh.host ESMTP Postfix\nC: EHLO localhost\nS: 250-strawberry.munkeh.host\r\n250-PIPELINING\r\n250-SIZE 30720000\r\n250-ETRN\r\n250-AUTH PLAIN\r\n250-AUTH=PLAIN\r\n250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 CHUNKING\nC: AUTH PLAIN [credentials]\nS: 235 2.7.0 Authentication successful\nC: MAIL FROM:\nS: 250 2.1.0 Ok\nC: RCPT TO:\nS: 250 2.1.5 Ok\nC: DATA\nS: 354 End data with .\nC: [message body]\nS: 250 2.0.0 Ok: queued as CAB59123CBF\nC: QUIT\nS: 221 2.0.0 Bye"}} diff --git a/storage/settings.php b/storage/settings.php new file mode 100644 index 0000000..d2bfa58 --- /dev/null +++ b/storage/settings.php @@ -0,0 +1,4 @@ + 'AudioCore V1.5', +]; diff --git a/storage/update_cache.json b/storage/update_cache.json new file mode 100644 index 0000000..3bb6c6e --- /dev/null +++ b/storage/update_cache.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "configured": true, + "current_version": "1.5.0", + "latest_version": "1.5.0", + "update_available": false, + "channel": "stable", + "manifest_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/raw/branch/main/update.json", + "error": "", + "checked_at": "2026-03-04T20:31:19+00:00", + "download_url": "https://lab.codemunkeh.io/audiocore/releases/download/v1.5.0/audiocore-v1.5.0.zip", + "changelog_url": "https://lab.codemunkeh.io/audiocore/releases/tag/v1.5.0", + "notes": "Bug fixes and installer improvements.", + "fetched_at": 1772656279 +} \ No newline at end of file diff --git a/themes/_default/partials/readme.txt b/themes/_default/partials/readme.txt new file mode 100644 index 0000000..296ec12 --- /dev/null +++ b/themes/_default/partials/readme.txt @@ -0,0 +1 @@ +Theme assets go here. Keep design only, no logic. diff --git a/views/partials/footer.php b/views/partials/footer.php new file mode 100644 index 0000000..cc06f8c --- /dev/null +++ b/views/partials/footer.php @@ -0,0 +1,32 @@ + +
    +
    + +
    + + + > + + + +
    + + + - +
    +
    diff --git a/views/partials/header.php b/views/partials/header.php new file mode 100644 index 0000000..461c015 --- /dev/null +++ b/views/partials/header.php @@ -0,0 +1,145 @@ + 'Home', 'url' => '/'], + ['label' => 'Artists', 'url' => '/artists'], + ['label' => 'Releases', 'url' => '/releases'], + ['label' => 'Store', 'url' => '/store'], + ['label' => 'Contact', 'url' => '/contact'], + ]; +} + +$storeEnabled = Plugins::isEnabled('store'); +$cartCount = 0; +$cartTotal = 0.0; +$customerLoggedIn = false; +$hasAccountLink = false; +foreach ($activeLinks as $link) { + $linkUrl = trim((string)($link['url'] ?? '')); + if ($linkUrl === '/account') { + $hasAccountLink = true; + break; + } +} +if ($storeEnabled) { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $customerLoggedIn = !empty($_SESSION['ac_store_customer_email']); + $cart = $_SESSION['ac_cart'] ?? []; + if (is_array($cart)) { + foreach ($cart as $item) { + if (!is_array($item)) { + continue; + } + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $cartCount += $qty; + $cartTotal += ($price * $qty); + } + } +} + +$headerTitle = Settings::get('site_header_title', 'AudioCore V1.5'); +$headerTagline = Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'); +$headerBadgeText = Settings::get('site_header_badge_text', 'Independent catalog'); +$headerBrandMode = Settings::get('site_header_brand_mode', 'default'); +$headerMarkMode = Settings::get('site_header_mark_mode', 'text'); +$headerMarkText = Settings::get('site_header_mark_text', 'AC'); +$headerMarkIcon = preg_replace('/[^a-zA-Z0-9\\-\\s]/', '', Settings::get('site_header_mark_icon', 'fa-solid fa-music')) ?? 'fa-solid fa-music'; +$headerMarkIcon = trim($headerMarkIcon); +if ($headerMarkIcon === '' || strpos($headerMarkIcon, 'fa-') === false) { + $headerMarkIcon = 'fa-solid fa-music'; +} +$headerMarkBgStart = Settings::get('site_header_mark_bg_start', '#22f2a5'); +$headerMarkBgEnd = Settings::get('site_header_mark_bg_end', '#10252e'); +$headerLogoUrl = Settings::get('site_header_logo_url', ''); + +$effectiveMarkMode = $headerMarkMode; +if ($effectiveMarkMode === 'logo' && $headerLogoUrl === '') { + $effectiveMarkMode = 'icon'; +} +if ($effectiveMarkMode === 'icon' && $headerMarkIcon === '') { + $effectiveMarkMode = 'text'; +} +if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') { + $effectiveMarkMode = 'icon'; +} +?> +
    +
    +
    + +
    + +
    + +
    +
    + + + + + + + +
    +
    +
    +
    +
    +
    + +
    + +
    + +
    +
    + diff --git a/views/site/404.php b/views/site/404.php new file mode 100644 index 0000000..8fe7acd --- /dev/null +++ b/views/site/404.php @@ -0,0 +1,12 @@ + +
    +
    404
    +

    +

    +
    + +
    +
    Foundation
    +

    AudioCore V1.5

    +

    + New core scaffold. Modules will live under /modules and admin will manage navigation. +

    +
    + + + + + + + <?= htmlspecialchars($metaTitle, ENT_QUOTES, 'UTF-8') ?> + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + View Cart +
    +
    +
    + +
    + +
    + + + + + +