From 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 Mon Sep 17 00:00:00 2001 From: sillylaird Date: Thu, 3 Sep 2026 00:33:59 +0000 Subject: import live www.sillylaird.ca webroot --- admin.php | 3742 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3742 insertions(+) create mode 100644 admin.php (limited to 'admin.php') diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..29f8917 --- /dev/null +++ b/admin.php @@ -0,0 +1,3742 @@ + /mnt/slab/blog.sillylaird.ca/blog.sqlite (SQLite) + * - Changelog -> ./changelog/YYYY-changelog.txt (flat files) + * - Vibe -> ./vibe.php (PHP config) + * - Now -> ./now/now-data.php (PHP config) + * - StartPage -> ./startpage/cards.txt (flat file) + * - Journal -> ./journal/entries.php (PHP data) + * - Guestbook -> guestbook_db.messages (MySQL) + * - Collection -> ./gaming/collection/games.json (JSON) + * - MOTD -> ./motd/motd.json (JSON, waifu-bot) + * - Traffic -> /var/lib/sillylaird/traffic.sqlite (no IPs) + * + * Auth is the shared www session ($_SESSION['admin'], set by /admin/login.php). + * All state changes are POST + CSRF, then redirect (PRG) with a flash message. + */ + +require_once __DIR__ . '/partials/session.php'; + +if (empty($_SESSION['admin'])) { + header('Location: /admin/login.php?ref=' . urlencode($_SERVER['REQUEST_URI'] ?? '/admin.php')); + exit; +} +if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); +} +$csrf = $_SESSION['csrf']; + +const BLOG_DB = '/mnt/slab/blog.sillylaird.ca/blog.sqlite'; +const CHANGELOG_DIR = __DIR__ . '/changelog'; +const VIBE_FILE = __DIR__ . '/vibe.php'; +const NOW_FILE = __DIR__ . '/now/now-data.php'; +const GUESTBOOK_CONFIG = '/mnt/slab/guestbook.sillylaird.ca/config.php'; +const JOURNAL_ENTRIES = __DIR__ . '/journal/entries.php'; +const STARTPAGE_CARDS = __DIR__ . '/startpage/cards.txt'; +const COLLECTION_FILE = __DIR__ . '/gaming/collection/games.json'; +const COLLECTION_IMAGES_DIR = __DIR__ . '/gaming/collection/images'; +const COLLECTION_STATUSES = ['Owned', 'Playing', 'Completed', 'Wishlist', 'Sold', 'Digital']; +const RADIO_STATIONS = '/mnt/slab/radio.sillylaird.ca/stations.json'; +const RADIO_META_TYPES = ['icecast', 'radio', 'plaza', 'gensokyo', 'somafm']; +const RUNESCAPE_FILE = __DIR__ . '/gaming/runescape/data.json'; +const RUNESCAPE_IMAGES_DIR = __DIR__ . '/gaming/runescape/images'; +const MOTD_FILE = __DIR__ . '/motd/motd.json'; +const MOTD_PAGE_FILE = __DIR__ . '/motd/motd-data.php'; +const MOTD_HISTORY_MAX = 20; +const MOTD_TITLE_MAX = 80; +const MOTD_HEADING_MAX = 80; +const MOTD_EMOJI_MAX = 16; +const MOTD_URL_MAX = 300; +const MOTD_BODY_MAX = 2000; +const MOTD_DEFAULT_URL = 'https://www.sillylaird.ca/motd/'; +const MOTD_DEFAULT_HEADING = 'MOTD'; + +function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } + +function check_csrf(): void { + global $csrf; + if (!isset($_POST['csrf']) || !is_string($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) { + http_response_code(403); + exit('Invalid CSRF token. Go back, reload, and try again.'); + } +} + +function flash_set(string $type, string $msg): void { $_SESSION['admin_flash'] = ['type' => $type, 'msg' => $msg]; } +function flash_get(): ?array { $f = $_SESSION['admin_flash'] ?? null; unset($_SESSION['admin_flash']); return $f; } +function redirect_section(string $section): void { header('Location: /admin.php?section=' . urlencode($section)); exit; } + +function valid_url_or_path(string $url): bool { + if ($url === '') return false; + if ($url[0] === '/') return !str_starts_with($url, '//') && !str_contains($url, "\0"); + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''); + if (in_array($scheme, ['http', 'https', 'gemini', 'gopher'], true)) return filter_var($url, FILTER_VALIDATE_URL) !== false; + return false; +} + +function valid_asset_ref(string $ref): bool { + if ($ref === '' || str_contains($ref, "\0")) return false; + if (valid_url_or_path($ref)) return true; + return !str_contains($ref, '..') && preg_match('/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/', $ref); +} + +/** @return array}> */ +function parse_startpage_cards(string $raw): array { + $cards = []; + $cur = null; + foreach (explode("\n", str_replace("\r\n", "\n", $raw)) as $line) { + $line = trim($line); + if ($line === '') continue; + if ($line[0] === '#') { + if ($cur !== null) $cards[] = $cur; + $parts = array_map('trim', explode('|', ltrim($line, '#'), 3)); + $title = $parts[0] ?? ''; + $image = $parts[1] ?? ''; + $alt = $parts[2] ?? 'Visual'; + if ($title === '' || !valid_asset_ref($image)) { + throw new RuntimeException('Card headers must be "# Title | image | alt".'); + } + $cur = ['title' => $title, 'image' => $image, 'alt' => $alt !== '' ? $alt : 'Visual', 'links' => []]; + continue; + } + if ($cur === null) throw new RuntimeException('Start each card with "# Title | image | alt".'); + $parts = array_map('trim', explode('|', $line, 2)); + $label = $parts[0] ?? ''; + $url = $parts[1] ?? ''; + if ($label === '' || !valid_url_or_path($url)) { + throw new RuntimeException('Link rows must be "Label | URL".'); + } + $cur['links'][] = ['label' => $label, 'url' => $url]; + } + if ($cur !== null) $cards[] = $cur; + foreach ($cards as $card) { + if ($card['links'] === []) throw new RuntimeException('Every StartPage card needs at least one link.'); + } + return $cards; +} + +/** @param array}> $cards */ +function format_startpage_cards(array $cards): string { + $out = ''; + foreach ($cards as $card) { + $out .= '# ' . $card['title'] . ' | ' . $card['image'] . ' | ' . $card['alt'] . "\n"; + foreach ($card['links'] as $link) { + $out .= $link['label'] . ' | ' . $link['url'] . "\n"; + } + $out .= "\n"; + } + return $out; +} + +function startpage_cards_text(): string { + $raw = is_file(STARTPAGE_CARDS) ? file_get_contents(STARTPAGE_CARDS) : ''; + return is_string($raw) ? $raw : ''; +} + +/** @return array */ +function journal_entries(): array { + $entries = is_file(JOURNAL_ENTRIES) ? require JOURNAL_ENTRIES : []; + if (!is_array($entries)) return []; + $out = []; + foreach ($entries as $entry) { + $date = (string)($entry['date'] ?? ''); + $body = (string)($entry['body'] ?? ''); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && trim($body) !== '') { + $out[] = ['date' => $date, 'body' => $body]; + } + } + return $out; +} + +/** @param array $entries */ +function save_journal_entries(array $entries): bool { + usort($entries, fn($a, $b) => strcmp($b['date'], $a['date'])); + $php = " + */ +function collection_normalize_inventory($list): array { + $out = []; + if (!is_array($list)) return $out; + foreach ($list as $item) { + if (!is_array($item)) continue; + $name = trim((string)($item['name'] ?? '')); + if ($name === '') continue; + $id = trim((string)($item['id'] ?? '')); + if ($id === '') $id = collection_slug($name, 'item'); + $status = trim((string)($item['status'] ?? 'Owned')); + if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned'; + $image = trim((string)($item['image'] ?? '')); + if ($image !== '' && !valid_asset_ref($image)) $image = ''; + $model = trim((string)($item['model'] ?? '')); + if ($model === '') $model = $name; + $out[] = [ + 'id' => $id, + 'name' => $name, + 'model' => $model, + 'status' => $status, + 'notes' => (string)($item['notes'] ?? ''), + 'image' => $image, + ]; + } + return $out; +} + +/** + * @return array{ + * consoles: array, + * hardware: array, + * systems: array}>, + * online: array}> + * } + */ +function collection_load(): array { + $empty = ['consoles' => [], 'hardware' => [], 'systems' => [], 'online' => []]; + if (!is_file(COLLECTION_FILE)) { + return $empty; + } + $raw = file_get_contents(COLLECTION_FILE); + if ($raw === false || trim($raw) === '') { + return $empty; + } + $data = json_decode($raw, true); + if (!is_array($data)) { + return $empty; + } + $consoles = collection_normalize_inventory($data['consoles'] ?? $data['videogames'] ?? []); + $hardware = collection_normalize_inventory($data['hardware'] ?? []); + $systems = []; + foreach (($data['systems'] ?? []) as $sys) { + if (!is_array($sys)) continue; + $name = trim((string)($sys['name'] ?? '')); + $id = trim((string)($sys['id'] ?? '')); + if ($name === '') continue; + if ($id === '') $id = collection_slug($name, 'system'); + $games = []; + foreach (($sys['games'] ?? []) as $game) { + if (!is_array($game)) continue; + $title = trim((string)($game['title'] ?? '')); + if ($title === '') continue; + $gid = trim((string)($game['id'] ?? '')); + if ($gid === '') $gid = collection_slug($title, 'game'); + $status = trim((string)($game['status'] ?? 'Owned')); + if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned'; + $image = trim((string)($game['image'] ?? '')); + if ($image !== '' && !valid_asset_ref($image)) $image = ''; + $games[] = [ + 'id' => $gid, + 'title' => $title, + 'status' => $status, + 'notes' => (string)($game['notes'] ?? ''), + 'image' => $image, + ]; + } + $systems[] = ['id' => $id, 'name' => $name, 'games' => $games]; + } + $online = []; + foreach (($data['online'] ?? []) as $cat) { + if (!is_array($cat)) continue; + $name = trim((string)($cat['name'] ?? '')); + $id = trim((string)($cat['id'] ?? '')); + if ($name === '') continue; + if ($id === '') $id = collection_slug($name, 'online'); + $entries = []; + foreach (($cat['entries'] ?? []) as $entry) { + if (!is_array($entry)) continue; + $title = trim((string)($entry['title'] ?? '')); + if ($title === '') continue; + $eid = trim((string)($entry['id'] ?? '')); + if ($eid === '') $eid = collection_slug($title, 'entry'); + $url = trim((string)($entry['url'] ?? '')); + if ($url !== '' && !valid_url_or_path($url)) $url = ''; + $status = trim((string)($entry['status'] ?? 'Owned')); + if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned'; + $image = trim((string)($entry['image'] ?? '')); + if ($image !== '' && !valid_asset_ref($image)) $image = ''; + $entries[] = [ + 'id' => $eid, + 'title' => $title, + 'url' => $url, + 'status' => $status, + 'notes' => (string)($entry['notes'] ?? ''), + 'image' => $image, + ]; + } + $online[] = ['id' => $id, 'name' => $name, 'entries' => $entries]; + } + return ['consoles' => $consoles, 'hardware' => $hardware, 'systems' => $systems, 'online' => $online]; +} + +/** @param array{consoles?: array, hardware?: array, systems?: array, online?: array} $data */ +/* --------------------------------------------------------------------------- + Radio — station list for radio.sillylaird.ca (stations.json). + The landing page, /relay and /meta on that host all read this same file. + --------------------------------------------------------------------------- */ + +/** @return array> */ +function radio_load(): array { + if (!is_file(RADIO_STATIONS)) return []; + $raw = file_get_contents(RADIO_STATIONS); + $data = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($data)) return []; + $out = []; + foreach (($data['stations'] ?? []) as $s) { + if (!is_array($s)) continue; + $name = trim((string)($s['name'] ?? '')); + $url = trim((string)($s['url'] ?? '')); + if ($name === '' || $url === '') continue; + $id = trim((string)($s['id'] ?? '')); + if ($id === '' || !preg_match('/^[A-Za-z0-9._-]{1,64}$/', $id)) $id = collection_slug($name, 'station'); + $metaType = strtolower(trim((string)($s['meta']['type'] ?? ''))); + $metaUrl = trim((string)($s['meta']['url'] ?? '')); + $row = [ + 'id' => $id, + 'name' => $name, + 'url' => $url, + 'homepage' => trim((string)($s['homepage'] ?? '')), + 'genre' => trim((string)($s['genre'] ?? '')), + 'codec' => trim((string)($s['codec'] ?? '')), + 'relay' => !empty($s['relay']), + ]; + if (in_array($metaType, RADIO_META_TYPES, true) && $metaUrl !== '') { + $row['meta'] = ['type' => $metaType, 'url' => $metaUrl]; + } + $out[] = $row; + } + return $out; +} + +/** @param array> $stations */ +function radio_save(array $stations): bool { + $json = json_encode(['stations' => array_values($stations)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) return false; + return file_put_contents(RADIO_STATIONS, $json . "\n", LOCK_EX) !== false; +} + +/** @return array{enabled:bool,title:string,body:string,updated:string,rev:int,push:int,announce_on_join:bool,announce_on_change:bool,history:array} */ +function motd_default(): array { + return [ + 'enabled' => false, + 'title' => '', + 'emoji' => '', + 'heading' => MOTD_DEFAULT_HEADING, + 'url' => MOTD_DEFAULT_URL, + 'body' => '', + 'updated' => '', + 'rev' => 0, + 'push' => 0, + 'announce_on_join' => false, + 'announce_on_change' => false, + 'history' => [], + ]; +} + +/** @return array{enabled:bool,title:string,body:string,updated:string,rev:int,push:int,announce_on_join:bool,announce_on_change:bool,history:array} */ +function motd_load(): array { + $out = motd_default(); + if (!is_file(MOTD_FILE)) return $out; + $raw = file_get_contents(MOTD_FILE); + $data = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($data)) return $out; + $out['enabled'] = !empty($data['enabled']); + $out['title'] = (string)($data['title'] ?? ''); + $out['emoji'] = (string)($data['emoji'] ?? ''); + $out['heading'] = array_key_exists('heading', $data) ? (string)$data['heading'] : MOTD_DEFAULT_HEADING; + $out['url'] = array_key_exists('url', $data) ? (string)$data['url'] : MOTD_DEFAULT_URL; + $out['body'] = (string)($data['body'] ?? ''); + $out['updated'] = (string)($data['updated'] ?? ''); + $out['rev'] = (int)($data['rev'] ?? 0); + $out['push'] = (int)($data['push'] ?? 0); + $out['announce_on_join'] = array_key_exists('announce_on_join', $data) ? !empty($data['announce_on_join']) : false; + $out['announce_on_change'] = array_key_exists('announce_on_change', $data) ? !empty($data['announce_on_change']) : false; + $history = []; + foreach (($data['history'] ?? []) as $row) { + if (!is_array($row)) continue; + $body = (string)($row['body'] ?? ''); + if (trim($body) === '') continue; + $history[] = [ + 'title' => (string)($row['title'] ?? ''), + 'body' => $body, + 'updated' => (string)($row['updated'] ?? ''), + 'rev' => (int)($row['rev'] ?? 0), + ]; + } + $out['history'] = $history; + return $out; +} + +function motd_save(array $data): bool { + $payload = [ + 'enabled' => !empty($data['enabled']), + 'title' => (string)($data['title'] ?? ''), + 'emoji' => (string)($data['emoji'] ?? ''), + 'heading' => (string)($data['heading'] ?? MOTD_DEFAULT_HEADING), + 'url' => (string)($data['url'] ?? MOTD_DEFAULT_URL), + 'body' => (string)($data['body'] ?? ''), + 'updated' => (string)($data['updated'] ?? ''), + 'rev' => (int)($data['rev'] ?? 0), + 'push' => (int)($data['push'] ?? 0), + 'announce_on_join' => !empty($data['announce_on_join']), + 'announce_on_change' => !empty($data['announce_on_change']), + 'history' => array_values($data['history'] ?? []), + ]; + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) return false; + $dir = dirname(MOTD_FILE); + if (!is_dir($dir) && !@mkdir($dir, 0775, true)) return false; + $tmp = MOTD_FILE . '.tmp'; + if (file_put_contents($tmp, $json . "\n", LOCK_EX) === false) return false; + if (!rename($tmp, MOTD_FILE)) { + @unlink($tmp); + return false; + } + @chmod(MOTD_FILE, 0644); + + $export = var_export([ + 'title' => $payload['title'], + 'heading' => $payload['heading'], + 'body' => $payload['body'], + 'updated' => $payload['updated'], + 'rev' => $payload['rev'], + 'history' => $payload['history'], + ], true); + $php = " (string)($data['title'] ?? ''), + 'body' => (string)($data['body'] ?? ''), + 'updated' => (string)($data['updated'] ?? ''), + 'rev' => (int)($data['rev'] ?? 0), + ]; + $history = $data['history'] ?? []; + array_unshift($history, $entry); + $data['history'] = array_slice($history, 0, MOTD_HISTORY_MAX); + return $data; +} + +function radio_stream_url_ok(string $url): bool { + if ($url === '') return false; + if ($url[0] === '/') return !str_starts_with($url, '//') && !str_contains($url, "\0"); + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST) !== null; +} + +function collection_save(array $data): bool { + $payload = [ + 'consoles' => array_values($data['consoles'] ?? []), + 'hardware' => array_values($data['hardware'] ?? []), + 'systems' => array_values($data['systems'] ?? []), + 'online' => array_values($data['online'] ?? []), + ]; + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) return false; + return file_put_contents(COLLECTION_FILE, $json . "\n", LOCK_EX) !== false; +} + +/** Public URL for a collection left-side image (filename, site path, or absolute URL). */ +function collection_image_url(string $image): string { + $image = trim($image); + if ($image === '') return ''; + if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://') || str_starts_with($image, '/')) { + return $image; + } + if (str_contains($image, '..') || str_contains($image, "\0")) return ''; + return '/gaming/collection/images/' . rawurlencode(basename($image)); +} + +/** + * Handle multipart left-photo upload into gaming/collection/images/. + * @return array{ok:bool,filename:string,uploaded:bool,err:string} + */ +function collection_process_image_upload(string $field, string $slugBase, string $current): array { + if (!empty($_POST['image_clear'])) { + return ['ok' => true, 'filename' => '', 'uploaded' => false, 'err' => '']; + } + if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) { + return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => '']; + } + $f = $_FILES[$field]; + $err = (int)($f['error'] ?? UPLOAD_ERR_NO_FILE); + if ($err === UPLOAD_ERR_NO_FILE) { + return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => '']; + } + if ($err !== UPLOAD_ERR_OK) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image upload failed (code ' . $err . ').']; + } + if ((int)($f['size'] ?? 0) > 2 * 1024 * 1024) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image too large (max 2 MB).']; + } + $tmp = (string)($f['tmp_name'] ?? ''); + if ($tmp === '' || !is_uploaded_file($tmp)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Invalid upload.']; + } + $mime = ''; + if (class_exists('finfo')) { + $fi = new finfo(FILEINFO_MIME_TYPE); + $mime = (string)$fi->file($tmp); + } elseif (function_exists('mime_content_type')) { + $mime = (string)mime_content_type($tmp); + } + $map = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + if (!isset($map[$mime])) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Only JPEG, PNG, GIF, or WebP allowed.']; + } + $base = collection_slug($slugBase !== '' ? $slugBase : 'photo', 'photo'); + $name = $base . '-' . bin2hex(random_bytes(3)) . '.' . $map[$mime]; + if (!is_dir(COLLECTION_IMAGES_DIR) && !@mkdir(COLLECTION_IMAGES_DIR, 0775, true)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Images folder missing and could not be created.']; + } + $dest = COLLECTION_IMAGES_DIR . '/' . $name; + if (!move_uploaded_file($tmp, $dest)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Could not save image (permissions on images/?).']; + } + @chmod($dest, 0644); + return ['ok' => true, 'filename' => $name, 'uploaded' => true, 'err' => '']; +} + +/** + * Resolve image field from text input + optional file upload. + * @return array{ok:bool,image:string,err:string} + */ +function collection_resolve_image_field(string $slugBase): array { + $image = trim((string)($_POST['image'] ?? '')); + if ($image !== '' && !valid_asset_ref($image)) { + return ['ok' => false, 'image' => '', 'err' => 'Image must be a safe filename, site path, or http(s) URL.']; + } + $up = collection_process_image_upload('image_file', $slugBase, $image); + if (!$up['ok']) { + return ['ok' => false, 'image' => $image, 'err' => $up['err']]; + } + if ($up['uploaded']) { + return ['ok' => true, 'image' => $up['filename'], 'err' => '']; + } + if (!empty($_POST['image_clear'])) { + return ['ok' => true, 'image' => '', 'err' => '']; + } + return ['ok' => true, 'image' => $image, 'err' => '']; +} + +/** + * @return array{ + * welcome: array, + * screenshots: array, + * eras: array,links:array}>}>, + * links_section: array{title:string,disclaimer:string,items:array} + * } + */ +function runescape_load(): array { + $empty = [ + 'welcome' => [], + 'screenshots' => [], + 'eras' => [], + 'links_section' => ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []], + ]; + if (!is_file(RUNESCAPE_FILE)) return $empty; + $raw = file_get_contents(RUNESCAPE_FILE); + if ($raw === false || trim($raw) === '') return $empty; + $data = json_decode($raw, true); + if (!is_array($data)) return $empty; + $welcome = []; + foreach (($data['welcome'] ?? []) as $line) { + $line = trim((string)$line); + if ($line !== '') $welcome[] = $line; + } + $screenshots = []; + foreach (($data['screenshots'] ?? []) as $shot) { + if (!is_array($shot)) continue; + $filename = trim((string)($shot['filename'] ?? '')); + if ($filename === '' || str_contains($filename, '..') || str_contains($filename, '/')) continue; + $screenshots[] = [ + 'id' => trim((string)($shot['id'] ?? '')) ?: $filename, + 'filename' => $filename, + 'caption' => (string)($shot['caption'] ?? ''), + ]; + } + $eras = []; + foreach (($data['eras'] ?? []) as $era) { + if (!is_array($era)) continue; + $title = trim((string)($era['title'] ?? '')); + if ($title === '') continue; + $id = trim((string)($era['id'] ?? '')) ?: collection_slug($title, 'era'); + $servers = []; + foreach (($era['servers'] ?? []) as $srv) { + if (!is_array($srv)) continue; + $name = trim((string)($srv['name'] ?? '')); + if ($name === '') continue; + $sid = trim((string)($srv['id'] ?? '')) ?: collection_slug($name, 'server'); + $url = trim((string)($srv['url'] ?? '')); + if ($url !== '' && !valid_url_or_path($url)) $url = ''; + $highlights = []; + foreach (($srv['highlights'] ?? []) as $hl) { + $hl = trim((string)$hl); + if ($hl !== '') $highlights[] = $hl; + } + $links = []; + foreach (($srv['links'] ?? []) as $lk) { + if (!is_array($lk)) continue; + $label = trim((string)($lk['label'] ?? '')); + $lurl = trim((string)($lk['url'] ?? '')); + if ($label === '' || $lurl === '' || !valid_url_or_path($lurl)) continue; + $links[] = ['label' => $label, 'url' => $lurl]; + } + $srvImage = trim((string)($srv['image'] ?? '')); + if ($srvImage !== '' && !valid_asset_ref($srvImage)) $srvImage = ''; + $servers[] = [ + 'id' => $sid, + 'name' => $name, + 'url' => $url, + 'featured' => !empty($srv['featured']), + 'desc' => (string)($srv['desc'] ?? ''), + 'highlights' => $highlights, + 'links' => $links, + 'image' => $srvImage, + ]; + } + $eraImage = trim((string)($era['image'] ?? '')); + if ($eraImage !== '' && !valid_asset_ref($eraImage)) $eraImage = ''; + $eras[] = [ + 'id' => $id, + 'title' => $title, + 'subtitle' => (string)($era['subtitle'] ?? ''), + 'image' => $eraImage, + 'servers' => $servers, + ]; + } + $linksSection = ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []]; + if (is_array($data['links_section'] ?? null)) { + $ls = $data['links_section']; + $linksSection['title'] = trim((string)($ls['title'] ?? '')) ?: 'Server Lists'; + $linksSection['disclaimer'] = (string)($ls['disclaimer'] ?? ''); + foreach (($ls['items'] ?? []) as $item) { + if (!is_array($item)) continue; + $label = trim((string)($item['label'] ?? '')); + $lurl = trim((string)($item['url'] ?? '')); + if ($label === '' || $lurl === '' || !valid_url_or_path($lurl)) continue; + $linksSection['items'][] = ['label' => $label, 'url' => $lurl, 'desc' => (string)($item['desc'] ?? '')]; + } + } + return ['welcome' => $welcome, 'screenshots' => $screenshots, 'eras' => $eras, 'links_section' => $linksSection]; +} + +/** @param array{welcome?: array, screenshots?: array, eras?: array, links_section?: array} $data */ +function runescape_save(array $data): bool { + $payload = [ + 'welcome' => array_values($data['welcome'] ?? []), + 'screenshots' => array_values($data['screenshots'] ?? []), + 'eras' => array_values($data['eras'] ?? []), + 'links_section' => $data['links_section'] ?? ['title' => 'Server Lists', 'disclaimer' => '', 'items' => []], + ]; + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) return false; + return file_put_contents(RUNESCAPE_FILE, $json . "\n", LOCK_EX) !== false; +} + +/** + * Handle multipart screenshot upload into gaming/runescape/images/. + * @return array{ok:bool,filename:string,err:string} + */ +/** + * Handle a multipart upload into gaming/runescape/images/ from an arbitrary $_FILES field. + * @return array{ok:bool,filename:string,uploaded:bool,err:string} + */ +function runescape_process_image_upload(string $field, string $slugBase, string $current): array { + if (!empty($_POST[$field . '_clear'])) { + return ['ok' => true, 'filename' => '', 'uploaded' => false, 'err' => '']; + } + if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) { + return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => '']; + } + $f = $_FILES[$field]; + $err = (int)($f['error'] ?? UPLOAD_ERR_NO_FILE); + if ($err === UPLOAD_ERR_NO_FILE) { + return ['ok' => true, 'filename' => $current, 'uploaded' => false, 'err' => '']; + } + if ($err !== UPLOAD_ERR_OK) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image upload failed (code ' . $err . ').']; + } + if ((int)($f['size'] ?? 0) > 2 * 1024 * 1024) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Image too large (max 2 MB).']; + } + $tmp = (string)($f['tmp_name'] ?? ''); + if ($tmp === '' || !is_uploaded_file($tmp)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Invalid upload.']; + } + $mime = ''; + if (class_exists('finfo')) { + $fi = new finfo(FILEINFO_MIME_TYPE); + $mime = (string)$fi->file($tmp); + } elseif (function_exists('mime_content_type')) { + $mime = (string)mime_content_type($tmp); + } + $map = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp']; + if (!isset($map[$mime])) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Only JPEG, PNG, GIF, or WebP allowed.']; + } + $name = collection_slug($slugBase !== '' ? $slugBase : 'rs', 'rs') . '-' . bin2hex(random_bytes(4)) . '.' . $map[$mime]; + if (!is_dir(RUNESCAPE_IMAGES_DIR) && !@mkdir(RUNESCAPE_IMAGES_DIR, 0775, true)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Images folder missing and could not be created.']; + } + $dest = RUNESCAPE_IMAGES_DIR . '/' . $name; + if (!move_uploaded_file($tmp, $dest)) { + return ['ok' => false, 'filename' => $current, 'uploaded' => false, 'err' => 'Could not save image (permissions on images/?).']; + } + @chmod($dest, 0644); + return ['ok' => true, 'filename' => $name, 'uploaded' => true, 'err' => '']; +} + +/** + * Resolve an image field from text input (POST 'image') + optional file upload ($field). + * @return array{ok:bool,image:string,err:string} + */ +function runescape_resolve_image_field(string $field, string $slugBase): array { + $image = trim((string)($_POST['image'] ?? '')); + if ($image !== '' && !valid_asset_ref($image)) { + return ['ok' => false, 'image' => '', 'err' => 'Image must be a safe filename, site path, or http(s) URL.']; + } + $up = runescape_process_image_upload($field, $slugBase, $image); + if (!$up['ok']) { + return ['ok' => false, 'image' => $image, 'err' => $up['err']]; + } + if ($up['uploaded']) { + return ['ok' => true, 'image' => $up['filename'], 'err' => '']; + } + if (!empty($_POST[$field . '_clear'])) { + return ['ok' => true, 'image' => '', 'err' => '']; + } + return ['ok' => true, 'image' => $image, 'err' => '']; +} + +/** Backwards-compatible screenshot upload (fixed field name, no text-path option). */ +function runescape_process_screenshot_upload(): array { + $up = runescape_process_image_upload('screenshot', 'rs', ''); + if (!$up['uploaded'] && $up['ok']) { + return ['ok' => false, 'filename' => '', 'err' => 'No file uploaded.']; + } + return ['ok' => $up['ok'], 'filename' => $up['filename'], 'err' => $up['err']]; +} + +/** Public URL for a runescape image filename (screenshot, era, or server). */ +function runescape_image_url(string $image): string { + $image = trim($image); + if ($image === '') return ''; + if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://') || str_starts_with($image, '/')) { + return $image; + } + if (str_contains($image, '..') || str_contains($image, "\0")) return ''; + return '/gaming/runescape/images/' . rawurlencode(basename($image)); +} + +function blog_db(): PDO { + static $db = null; + if ($db === null) { + $db = new PDO('sqlite:' . BLOG_DB); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + return $db; +} + +/** @return PDO|null null when the guestbook DB can't be reached. */ +function guestbook_db(): ?PDO { + static $pdo = null; + if ($pdo !== null) return $pdo ?: null; + if (!is_file(GUESTBOOK_CONFIG)) { $pdo = false; return null; } + $host = $db = $user = $pass = $charset = null; + require GUESTBOOK_CONFIG; // sets $host,$db,$user,$pass,$charset + try { + $pdo = new PDO( + "mysql:host=$host;dbname=$db;charset=$charset", + $user, $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + } catch (Throwable $e) { + $pdo = false; + return null; + } + return $pdo; +} + +function vibe_type(string $url): string { + if ($url === '') return 'none'; + $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION)); + if (in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg','avif'], true)) return 'image'; + if (in_array($ext, ['mp4','webm','ogv','mov'], true)) return 'video'; + if (in_array($ext, ['mp3','flac','wav','ogg','aac','opus','m4a'], true)) return 'audio'; + if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url)) return 'youtube'; + if (preg_match('/vimeo\.com\/(?:video\/)?\d+/', $url)) return 'vimeo'; + if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/[A-Za-z0-9]+#i', $url)) return 'dailymotion'; + if (preg_match('#bilibili\.com/video/(?:BV[A-Za-z0-9]+|av\d+)#i', $url)) return 'bilibili'; + if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/(?:sm|nm|so)?\d+#i', $url)) return 'niconico'; + return 'link'; +} + +/** + * Build an iframe embed URL for a supported video provider, or '' if the URL + * is not an embeddable player. Mirrors partials/partials_vibe.php so the admin + * preview matches what the live homepage renders. + */ +function vibe_embed_url(string $url): string { + if ($url === '') return ''; + if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url, $m)) { + return 'https://www.youtube-nocookie.com/embed/' . rawurlencode($m[1]) . '?rel=0&modestbranding=1'; + } + if (preg_match('/vimeo\.com\/(?:video\/)?(\d+)/', $url, $m)) { + return 'https://player.vimeo.com/video/' . rawurlencode($m[1]) . '?dnt=1'; + } + if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/([A-Za-z0-9]+)#i', $url, $m)) { + return 'https://geo.dailymotion.com/player.html?video=' . rawurlencode($m[1]); + } + if (preg_match('#bilibili\.com/video/(BV[A-Za-z0-9]+)#i', $url, $m)) { + return 'https://player.bilibili.com/player.html?bvid=' . rawurlencode($m[1]) . '&page=1&high_quality=1&danmaku=0'; + } + if (preg_match('#bilibili\.com/video/av(\d+)#i', $url, $m)) { + return 'https://player.bilibili.com/player.html?aid=' . rawurlencode($m[1]) . '&page=1&high_quality=1&danmaku=0'; + } + if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/((?:sm|nm|so)?\d+)#i', $url, $m)) { + return 'https://embed.nicovideo.jp/watch/' . rawurlencode($m[1]); + } + return ''; +} + +$section = $_GET['section'] ?? 'blog'; +if (!in_array($section, ['blog', 'changelog', 'vibe', 'now', 'motd', 'guestbook', 'journal', 'startpage', 'collection', 'runescape', 'radio', 'traffic'], true)) { + $section = 'blog'; +} + +/* --------------------------------------------------------------------------- + Blog ZIP export (read-only, GET) — handled before any output. + --------------------------------------------------------------------------- */ +if ($section === 'blog' && isset($_GET['export'])) { + try { + $rows = blog_db()->query('SELECT * FROM posts ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); + $zipName = 'blog_export_' . date('Ymd_His') . '.zip'; + $tmp = tempnam(sys_get_temp_dir(), 'blogzip'); + $zip = new ZipArchive(); + if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) { + if (is_file(BLOG_DB)) $zip->addFile(BLOG_DB, 'blog.sqlite'); + $zip->addFromString('posts.json', json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $zip->close(); + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="' . $zipName . '"'); + header('Content-Length: ' . filesize($tmp)); + readfile($tmp); + @unlink($tmp); + exit; + } + @unlink($tmp); + flash_set('err', 'Could not create export archive.'); + } catch (Throwable $e) { + flash_set('err', 'Export failed: ' . $e->getMessage()); + } + redirect_section('blog'); +} + +/* --------------------------------------------------------------------------- + POST dispatch + --------------------------------------------------------------------------- */ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + check_csrf(); + $do = $_POST['do'] ?? ''; + + switch ($do) { + + case 'blog_save': { + $title = trim($_POST['title'] ?? ''); + $content = trim($_POST['content'] ?? ''); + $id = $_POST['id'] ?? ''; + if ($title === '') { flash_set('err', 'Title cannot be empty.'); redirect_section('blog'); } + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($title)), '-'); + if ($slug === '') $slug = 'post'; + try { + if (ctype_digit((string)$id)) { + // Keep the post's original date/path location; only refresh the slug. + $cur = blog_db()->prepare('SELECT year, month, day FROM posts WHERE id=?'); + $cur->execute([(int)$id]); + $r = $cur->fetch(PDO::FETCH_ASSOC) ?: ['year' => date('Y'), 'month' => date('m'), 'day' => date('d')]; + $path = sprintf('%04d/%02d/%02d/%s.html', $r['year'], $r['month'], $r['day'], $slug); + blog_db()->prepare('UPDATE posts SET title=?, slug=?, content=?, path=? WHERE id=?') + ->execute([$title, $slug, $content, $path, (int)$id]); + flash_set('ok', 'Post updated.'); + } else { + $y = date('Y'); $m = date('m'); $d = date('d'); + $path = "$y/$m/$d/$slug.html"; + blog_db()->prepare( + 'INSERT INTO posts (title, slug, content, year, month, day, created_at, path) + VALUES (?,?,?,?,?,?,?,?)' + )->execute([$title, $slug, $content, $y, $m, $d, date('Y-m-d H:i:s'), $path]); + flash_set('ok', 'Post created.'); + } + } catch (Throwable $e) { + flash_set('err', 'Blog save failed: ' . $e->getMessage()); + } + redirect_section('blog'); + } + + case 'blog_delete': { + $id = $_POST['id'] ?? ''; + if (ctype_digit((string)$id)) { + try { + blog_db()->prepare('DELETE FROM posts WHERE id=?')->execute([(int)$id]); + flash_set('ok', 'Post deleted.'); + } catch (Throwable $e) { + flash_set('err', 'Delete failed: ' . $e->getMessage()); + } + } + redirect_section('blog'); + } + + case 'changelog_add': { + $text = trim($_POST['entry'] ?? ''); + $date = trim($_POST['date'] ?? ''); + if ($date === '') $date = date('j F Y'); + $year = preg_match('/\b(\d{4})\b/', $date, $mm) ? $mm[1] : date('Y'); + if ($text === '') { flash_set('err', 'Entry text cannot be empty.'); redirect_section('changelog'); } + $file = CHANGELOG_DIR . '/' . $year . '-changelog.txt'; + $line = '* ' . $date . ': ' . $text . "\n"; + if (!is_file($file)) { + $content = "**$year ChangeLog**\n\n" . $line; + } else { + $existing = file_get_contents($file); + $pos = strpos($existing, "\n\n"); + if ($pos === false) { + $content = rtrim($existing) . "\n" . $line; + } else { + $content = substr($existing, 0, $pos + 2) . $line . substr($existing, $pos + 2); + } + } + if (file_put_contents($file, $content, LOCK_EX) !== false) { + @unlink('/tmp/sillylaird_recent_changelog.json'); // bust homepage cache + flash_set('ok', "Entry added to $year-changelog.txt."); + } else { + flash_set('err', 'Could not write changelog file (permissions?).'); + } + redirect_section('changelog'); + } + + case 'vibe_save': { + $desc = trim($_POST['description'] ?? ''); + $url = trim($_POST['url'] ?? ''); + if ($url !== '') { + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''); + if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array($scheme, ['http', 'https'], true)) { + flash_set('err', 'URL must be a valid http:// or https:// URL.'); + redirect_section('vibe'); + } + } + $export = var_export(['description' => $desc, 'url' => $url, 'updated' => date('Y-m-d')], true); + $php = " trim(ltrim($line, '#')), 'items' => []]; + } elseif ($cur !== null) { + $cur['items'][] = preg_replace('/^[-*]\s+/', '', $line); + } + } + if ($cur !== null && $cur['items'] !== []) $sections[] = $cur; + if ($sections === []) { + flash_set('err', 'Nothing to save. Start a section with "# Title", then one item per line.'); + redirect_section('now'); + } + $export = var_export(['updated' => date('Y-m-d'), 'sections' => $sections], true); + $php = " MOTD_TITLE_MAX) { + flash_set('err', 'Title is too long (max ' . MOTD_TITLE_MAX . ' characters).'); + redirect_section('motd'); + } + if (strlen($emoji) > MOTD_EMOJI_MAX) { + flash_set('err', 'Emoji is too long.'); + redirect_section('motd'); + } + if (strlen($heading) > MOTD_HEADING_MAX) { + flash_set('err', 'Heading is too long (max ' . MOTD_HEADING_MAX . ' characters).'); + redirect_section('motd'); + } + if (strlen($url) > MOTD_URL_MAX) { + flash_set('err', 'URL is too long.'); + redirect_section('motd'); + } + if ($url !== '' && !valid_url_or_path($url)) { + flash_set('err', 'URL must be empty, a site path, or http(s).'); + redirect_section('motd'); + } + if (strlen($body) > MOTD_BODY_MAX) { + flash_set('err', 'Body is too long (max ' . MOTD_BODY_MAX . ' characters).'); + redirect_section('motd'); + } + if ($body === '') { + flash_set('err', 'Body cannot be empty. Use Clear if you want it gone.'); + redirect_section('motd'); + } + $data = motd_load(); + $changed = $title !== $data['title'] || $body !== $data['body'] || $heading !== $data['heading']; + if ($changed && trim($data['body']) !== '') { + $data = motd_archive_current($data); + } + $data['title'] = $title; + $data['emoji'] = $emoji; + $data['heading'] = $heading; + $data['url'] = $url; + $data['body'] = $body; + $data['enabled'] = true; + $data['announce_on_join'] = false; + $data['announce_on_change'] = false; + $data['updated'] = date('c'); + $data['rev'] = (int)$data['rev'] + 1; + if (motd_save($data)) { + flash_set('ok', 'MOTD saved to /motd/. Use Announce now to post it in the room.'); + } else { + flash_set('err', 'Could not write motd/motd.json (permissions?).'); + } + redirect_section('motd'); + } + + case 'motd_announce': { + $data = motd_load(); + if (trim($data['body']) === '') { + flash_set('err', 'Nothing to announce. Save a MOTD first.'); + redirect_section('motd'); + } + $data['push'] = time(); + $data['updated'] = date('c'); + if (motd_save($data)) { + flash_set('ok', 'Pushed MOTD to the XMPP room. waifu will re-post it shortly.'); + } else { + flash_set('err', 'Could not write motd/motd.json (permissions?).'); + } + redirect_section('motd'); + } + + case 'motd_clear': { + $data = motd_load(); + if (trim($data['body']) !== '') { + $data = motd_archive_current($data); + } + $data['enabled'] = false; + $data['title'] = ''; + $data['body'] = ''; + $data['updated'] = date('c'); + $data['rev'] = (int)$data['rev'] + 1; + if (motd_save($data)) { + flash_set('ok', 'MOTD cleared. Previous text kept in history.'); + } else { + flash_set('err', 'Could not write motd/motd.json (permissions?).'); + } + redirect_section('motd'); + } + + case 'startpage_save': { + $raw = trim((string)($_POST['content'] ?? '')); + try { + $cards = parse_startpage_cards($raw); + if (file_put_contents(STARTPAGE_CARDS, format_startpage_cards($cards), LOCK_EX) !== false) { + flash_set('ok', 'StartPage cards saved (' . count($cards) . ' card(s)).'); + } else { + flash_set('err', 'Could not write StartPage cards.'); + } + } catch (Throwable $e) { + flash_set('err', 'StartPage save failed: ' . $e->getMessage()); + } + redirect_section('startpage'); + } + + case 'guestbook_delete': { + $db = guestbook_db(); + if (!$db) { flash_set('err', 'Guestbook DB unavailable.'); redirect_section('guestbook'); } + $ids = []; + if (isset($_POST['ids']) && is_array($_POST['ids'])) { + $ids = array_filter($_POST['ids'], fn($i) => ctype_digit((string)$i)); + } elseif (isset($_POST['id']) && ctype_digit((string)$_POST['id'])) { + $ids = [$_POST['id']]; + } + $ids = array_values(array_unique(array_map('intval', $ids))); + if ($ids === []) { flash_set('err', 'No messages selected.'); redirect_section('guestbook'); } + try { + $ph = implode(',', array_fill(0, count($ids), '?')); + $db->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids); + flash_set('ok', count($ids) . ' message(s) deleted.'); + } catch (Throwable $e) { + flash_set('err', 'Guestbook delete failed: ' . $e->getMessage()); + } + redirect_section('guestbook'); + } + + case 'journal_save': { + $original = trim($_POST['original_date'] ?? ''); + $date = trim($_POST['date'] ?? ''); + $body = trim($_POST['body'] ?? ''); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $body === '') { + flash_set('err', 'Journal entries need a YYYY-MM-DD date and body text.'); + redirect_section('journal'); + } + $entries = journal_entries(); + $saved = false; + foreach ($entries as &$entry) { + if (($original !== '' && $entry['date'] === $original) || ($original === '' && $entry['date'] === $date)) { + $entry = ['date' => $date, 'body' => $body]; + $saved = true; + break; + } + } + unset($entry); + if (!$saved) $entries[] = ['date' => $date, 'body' => $body]; + if (save_journal_entries($entries)) { + flash_set('ok', $saved ? 'Journal entry updated.' : 'Journal entry added.'); + } else { + flash_set('err', 'Could not write journal entries.'); + } + redirect_section('journal'); + } + + case 'journal_delete': { + $date = trim($_POST['date'] ?? ''); + $entries = array_values(array_filter(journal_entries(), fn($entry) => $entry['date'] !== $date)); + if (save_journal_entries($entries)) { + flash_set('ok', 'Journal entry deleted.'); + } else { + flash_set('err', 'Could not write journal entries.'); + } + redirect_section('journal'); + } + + case 'radio_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $url = trim((string)($_POST['url'] ?? '')); + $homepage = trim((string)($_POST['homepage'] ?? '')); + $genre = trim((string)($_POST['genre'] ?? '')); + $codec = trim((string)($_POST['codec'] ?? '')); + $metaType = strtolower(trim((string)($_POST['meta_type'] ?? ''))); + $metaUrl = trim((string)($_POST['meta_url'] ?? '')); + $relay = !empty($_POST['relay']); + + if ($name === '') { flash_set('err', 'Station needs a name.'); redirect_section('radio'); } + if (!radio_stream_url_ok($url)) { flash_set('err', 'Stream URL must be http(s) or a path on radio.sillylaird.ca.'); redirect_section('radio'); } + if ($homepage !== '' && !valid_url_or_path($homepage)) { flash_set('err', 'Homepage is not a valid URL.'); redirect_section('radio'); } + if ($metaType !== '' && !in_array($metaType, RADIO_META_TYPES, true)) { flash_set('err', 'Unknown now-playing API type.'); redirect_section('radio'); } + if ($metaType !== '' && !radio_stream_url_ok($metaUrl)) { flash_set('err', 'Now-playing API URL is not valid.'); redirect_section('radio'); } + // Relaying only makes sense for plain-http upstreams; https plays direct. + if ($relay && str_starts_with(strtolower($url), 'https://')) $relay = false; + + $stations = radio_load(); + $used = []; + foreach ($stations as $s) { + if ($original === '' || $s['id'] !== $original) $used[$s['id']] = true; + } + $id = collection_unique_id(collection_slug($name, 'station'), $used); + + $row = [ + 'id' => $id, + 'name' => $name, + 'url' => $url, + 'homepage' => $homepage, + 'genre' => $genre, + 'codec' => $codec, + 'relay' => $relay, + ]; + if ($metaType !== '') $row['meta'] = ['type' => $metaType, 'url' => $metaUrl]; + + $saved = false; + foreach ($stations as &$s) { + if ($original !== '' && $s['id'] === $original) { $s = $row; $saved = true; break; } + } + unset($s); + if (!$saved) $stations[] = $row; + + if (radio_save($stations)) { + flash_set('ok', $saved ? 'Station updated.' : 'Station added.'); + } else { + flash_set('err', 'Could not write stations.json (permissions?).'); + } + redirect_section('radio'); + } + + case 'radio_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $stations = array_values(array_filter(radio_load(), fn($s) => $s['id'] !== $id)); + if (radio_save($stations)) { + flash_set('ok', 'Station deleted.'); + } else { + flash_set('err', 'Could not write stations.json (permissions?).'); + } + redirect_section('radio'); + } + + case 'radio_move': { + $id = trim((string)($_POST['id'] ?? '')); + $dir = ($_POST['dir'] ?? '') === 'up' ? -1 : 1; + $stations = radio_load(); + foreach ($stations as $i => $s) { + if ($s['id'] !== $id) continue; + $j = $i + $dir; + if ($j >= 0 && $j < count($stations)) { + [$stations[$i], $stations[$j]] = [$stations[$j], $stations[$i]]; + if (!radio_save($stations)) flash_set('err', 'Could not write stations.json (permissions?).'); + } + break; + } + redirect_section('radio'); + } + + case 'collection_videogame_save': + case 'collection_console_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $model = trim((string)($_POST['model'] ?? '')); + $status = trim((string)($_POST['status'] ?? 'Owned')); + $notes = (string)($_POST['notes'] ?? ''); + if ($name === '') { + flash_set('err', 'Console needs a name.'); + redirect_section('collection'); + } + if ($model === '') $model = $name; + if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned'; + $imgRes = collection_resolve_image_field($name); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('collection'); + } + $data = collection_load(); + $used = []; + foreach ($data['consoles'] as $item) { + if ($original === '' || $item['id'] !== $original) { + $used[$item['id']] = true; + } + } + $id = collection_unique_id(collection_slug($name, 'console'), $used); + $row = [ + 'id' => $id, + 'name' => $name, + 'model' => $model, + 'status' => $status, + 'notes' => $notes, + 'image' => $imgRes['image'], + ]; + $saved = false; + foreach ($data['consoles'] as &$item) { + if ($original !== '' && $item['id'] === $original) { + $item = $row; + $saved = true; + break; + } + } + unset($item); + if (!$saved) { + $data['consoles'][] = $row; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'Console updated.' : 'Console added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_videogame_delete': + case 'collection_console_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + $data['consoles'] = array_values(array_filter( + $data['consoles'], + fn($item) => $item['id'] !== $id + )); + if (collection_save($data)) { + flash_set('ok', 'Console deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_hardware_item_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $model = trim((string)($_POST['model'] ?? '')); + $status = trim((string)($_POST['status'] ?? 'Owned')); + $notes = (string)($_POST['notes'] ?? ''); + if ($name === '') { + flash_set('err', 'Hardware item needs a name.'); + redirect_section('collection'); + } + if ($model === '') $model = $name; + if (!in_array($status, COLLECTION_STATUSES, true)) $status = 'Owned'; + $imgRes = collection_resolve_image_field($name); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('collection'); + } + $data = collection_load(); + $used = []; + foreach ($data['hardware'] as $item) { + if ($original === '' || $item['id'] !== $original) { + $used[$item['id']] = true; + } + } + $id = collection_unique_id(collection_slug($name, 'hw'), $used); + $row = [ + 'id' => $id, + 'name' => $name, + 'model' => $model, + 'status' => $status, + 'notes' => $notes, + 'image' => $imgRes['image'], + ]; + $saved = false; + foreach ($data['hardware'] as &$item) { + if ($original !== '' && $item['id'] === $original) { + $item = $row; + $saved = true; + break; + } + } + unset($item); + if (!$saved) { + $data['hardware'][] = $row; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'Hardware updated.' : 'Hardware added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_hardware_item_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + $data['hardware'] = array_values(array_filter( + $data['hardware'], + fn($item) => $item['id'] !== $id + )); + if (collection_save($data)) { + flash_set('ok', 'Hardware deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_system_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + if ($name === '') { + flash_set('err', 'System name cannot be empty.'); + redirect_section('collection'); + } + $data = collection_load(); + $used = []; + foreach ($data['systems'] as $sys) { + if ($original === '' || $sys['id'] !== $original) { + $used[$sys['id']] = true; + } + } + $id = collection_unique_id(collection_slug($name, 'system'), $used); + $saved = false; + foreach ($data['systems'] as &$sys) { + if ($original !== '' && $sys['id'] === $original) { + $sys['id'] = $id; + $sys['name'] = $name; + $saved = true; + break; + } + } + unset($sys); + if (!$saved) { + $data['systems'][] = ['id' => $id, 'name' => $name, 'games' => []]; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'System updated.' : 'System added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_system_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + $data['systems'] = array_values(array_filter( + $data['systems'], + fn($sys) => $sys['id'] !== $id + )); + if (collection_save($data)) { + flash_set('ok', 'System deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_game_save': { + $systemId = trim((string)($_POST['system_id'] ?? '')); + $originalId = trim((string)($_POST['original_id'] ?? '')); + $title = trim((string)($_POST['title'] ?? '')); + $status = trim((string)($_POST['status'] ?? 'Owned')); + $notes = (string)($_POST['notes'] ?? ''); + if ($title === '' || $systemId === '') { + flash_set('err', 'Game needs a title and a system.'); + redirect_section('collection'); + } + if (!in_array($status, COLLECTION_STATUSES, true)) { + $status = 'Owned'; + } + $imgRes = collection_resolve_image_field($title); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('collection'); + } + $image = $imgRes['image']; + $data = collection_load(); + $sysIdx = null; + foreach ($data['systems'] as $i => $sys) { + if ($sys['id'] === $systemId) { $sysIdx = $i; break; } + } + if ($sysIdx === null) { + flash_set('err', 'Unknown system.'); + redirect_section('collection'); + } + $used = []; + foreach ($data['systems'][$sysIdx]['games'] as $game) { + if ($originalId === '' || $game['id'] !== $originalId) { + $used[$game['id']] = true; + } + } + $gid = collection_unique_id(collection_slug($title, 'game'), $used); + $row = [ + 'id' => $gid, + 'title' => $title, + 'status' => $status, + 'notes' => $notes, + 'image' => $image, + ]; + $saved = false; + foreach ($data['systems'][$sysIdx]['games'] as &$game) { + if ($originalId !== '' && $game['id'] === $originalId) { + $game = $row; + $saved = true; + break; + } + } + unset($game); + if (!$saved) { + $data['systems'][$sysIdx]['games'][] = $row; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'Game updated.' : 'Game added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_game_delete': { + $systemId = trim((string)($_POST['system_id'] ?? '')); + $gameId = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + foreach ($data['systems'] as &$sys) { + if ($sys['id'] !== $systemId) continue; + $sys['games'] = array_values(array_filter( + $sys['games'], + fn($g) => $g['id'] !== $gameId + )); + break; + } + unset($sys); + if (collection_save($data)) { + flash_set('ok', 'Game deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_online_cat_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + if ($name === '') { + flash_set('err', 'Online category name cannot be empty.'); + redirect_section('collection'); + } + $data = collection_load(); + $used = []; + foreach ($data['online'] as $cat) { + if ($original === '' || $cat['id'] !== $original) { + $used[$cat['id']] = true; + } + } + $id = collection_unique_id(collection_slug($name, 'online'), $used); + $saved = false; + foreach ($data['online'] as &$cat) { + if ($original !== '' && $cat['id'] === $original) { + $cat['id'] = $id; + $cat['name'] = $name; + $saved = true; + break; + } + } + unset($cat); + if (!$saved) { + $data['online'][] = ['id' => $id, 'name' => $name, 'entries' => []]; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'Online category updated.' : 'Online category added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_online_cat_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + $data['online'] = array_values(array_filter( + $data['online'], + fn($cat) => $cat['id'] !== $id + )); + if (collection_save($data)) { + flash_set('ok', 'Online category deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_online_entry_save': { + $catId = trim((string)($_POST['category_id'] ?? '')); + $originalId = trim((string)($_POST['original_id'] ?? '')); + $title = trim((string)($_POST['title'] ?? '')); + $url = trim((string)($_POST['url'] ?? '')); + $status = trim((string)($_POST['status'] ?? 'Owned')); + $notes = (string)($_POST['notes'] ?? ''); + if ($title === '' || $catId === '') { + flash_set('err', 'Song pack needs a title and a game/category.'); + redirect_section('collection'); + } + if ($url !== '' && !valid_url_or_path($url)) { + flash_set('err', 'URL must be http(s)/gemini/gopher or a site path starting with /.'); + redirect_section('collection'); + } + if (!in_array($status, COLLECTION_STATUSES, true)) { + $status = 'Owned'; + } + $imgRes = collection_resolve_image_field($title); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('collection'); + } + $image = $imgRes['image']; + $data = collection_load(); + $catIdx = null; + foreach ($data['online'] as $i => $cat) { + if ($cat['id'] === $catId) { $catIdx = $i; break; } + } + if ($catIdx === null) { + flash_set('err', 'Unknown online category.'); + redirect_section('collection'); + } + $used = []; + foreach ($data['online'][$catIdx]['entries'] as $entry) { + if ($originalId === '' || $entry['id'] !== $originalId) { + $used[$entry['id']] = true; + } + } + $eid = collection_unique_id(collection_slug($title, 'entry'), $used); + $row = [ + 'id' => $eid, + 'title' => $title, + 'url' => $url, + 'status' => $status, + 'notes' => $notes, + 'image' => $image, + ]; + $saved = false; + foreach ($data['online'][$catIdx]['entries'] as &$entry) { + if ($originalId !== '' && $entry['id'] === $originalId) { + $entry = $row; + $saved = true; + break; + } + } + unset($entry); + if (!$saved) { + $data['online'][$catIdx]['entries'][] = $row; + } + if (collection_save($data)) { + flash_set('ok', $saved ? 'Song pack updated.' : 'Song pack added.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'collection_online_entry_delete': { + $catId = trim((string)($_POST['category_id'] ?? '')); + $entryId = trim((string)($_POST['id'] ?? '')); + $data = collection_load(); + foreach ($data['online'] as &$cat) { + if ($cat['id'] !== $catId) continue; + $cat['entries'] = array_values(array_filter( + $cat['entries'], + fn($e) => $e['id'] !== $entryId + )); + break; + } + unset($cat); + if (collection_save($data)) { + flash_set('ok', 'Online entry deleted.'); + } else { + flash_set('err', 'Could not write games.json (permissions?).'); + } + redirect_section('collection'); + } + + case 'runescape_welcome_save': { + $raw = str_replace("\r\n", "\n", trim($_POST['welcome'] ?? '')); + $lines = []; + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if ($line !== '') $lines[] = $line; + } + $data = runescape_load(); + $data['welcome'] = $lines; + if (runescape_save($data)) { + flash_set('ok', 'Welcome list saved.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_screenshot_add': { + $caption = trim((string)($_POST['caption'] ?? '')); + $up = runescape_process_screenshot_upload(); + if (!$up['ok']) { + flash_set('err', $up['err']); + redirect_section('runescape'); + } + $data = runescape_load(); + $data['screenshots'][] = [ + 'id' => bin2hex(random_bytes(4)), + 'filename' => $up['filename'], + 'caption' => $caption, + ]; + if (runescape_save($data)) { + flash_set('ok', 'Screenshot added.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_screenshot_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = runescape_load(); + $toDelete = null; + foreach ($data['screenshots'] as $shot) { + if ($shot['id'] === $id) { $toDelete = $shot; break; } + } + $data['screenshots'] = array_values(array_filter($data['screenshots'], fn($s) => $s['id'] !== $id)); + if (runescape_save($data)) { + if ($toDelete !== null) { + $path = RUNESCAPE_IMAGES_DIR . '/' . basename($toDelete['filename']); + if (is_file($path)) @unlink($path); + } + flash_set('ok', 'Screenshot deleted.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_era_save': { + $original = trim((string)($_POST['original_id'] ?? '')); + $title = trim((string)($_POST['title'] ?? '')); + $subtitle = trim((string)($_POST['subtitle'] ?? '')); + if ($title === '') { + flash_set('err', 'Era needs a title.'); + redirect_section('runescape'); + } + $data = runescape_load(); + $imgRes = runescape_resolve_image_field('era_image_file', $title); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('runescape'); + } + $used = []; + foreach ($data['eras'] as $era) { + if ($original === '' || $era['id'] !== $original) $used[$era['id']] = true; + } + $saved = false; + foreach ($data['eras'] as &$era) { + if ($original !== '' && $era['id'] === $original) { + $era['title'] = $title; + $era['subtitle'] = $subtitle; + $era['image'] = $imgRes['image']; + $saved = true; + break; + } + } + unset($era); + if (!$saved) { + $id = collection_unique_id(collection_slug($title, 'era'), $used); + $data['eras'][] = ['id' => $id, 'title' => $title, 'subtitle' => $subtitle, 'image' => $imgRes['image'], 'servers' => []]; + } + if (runescape_save($data)) { + flash_set('ok', $saved ? 'Era updated.' : 'Era added.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_era_delete': { + $id = trim((string)($_POST['id'] ?? '')); + $data = runescape_load(); + $data['eras'] = array_values(array_filter($data['eras'], fn($e) => $e['id'] !== $id)); + if (runescape_save($data)) { + flash_set('ok', 'Era deleted (and its servers).'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_server_save': { + $eraId = trim((string)($_POST['era_id'] ?? '')); + $original = trim((string)($_POST['original_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $url = trim((string)($_POST['url'] ?? '')); + $featured = !empty($_POST['featured']); + $desc = trim((string)($_POST['desc'] ?? '')); + $highlights = []; + foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['highlights'] ?? ''))) as $line) { + $line = trim($line); + if ($line !== '') $highlights[] = $line; + } + $links = []; + foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['links'] ?? ''))) as $line) { + $line = trim($line); + if ($line === '') continue; + $parts = array_map('trim', explode('|', $line, 2)); + if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') { + $links[] = ['label' => $parts[0], 'url' => $parts[1]]; + } + } + if ($name === '') { + flash_set('err', 'Server needs a name.'); + redirect_section('runescape'); + } + if ($url !== '' && !valid_url_or_path($url)) { + flash_set('err', 'Server URL must be a valid http(s) URL.'); + redirect_section('runescape'); + } + $imgRes = runescape_resolve_image_field('srv_image_file', $name); + if (!$imgRes['ok']) { + flash_set('err', $imgRes['err']); + redirect_section('runescape'); + } + $data = runescape_load(); + $eraIdx = null; + foreach ($data['eras'] as $i => $era) { + if ($era['id'] === $eraId) { $eraIdx = $i; break; } + } + if ($eraIdx === null) { + flash_set('err', 'Pick an era for this server.'); + redirect_section('runescape'); + } + $used = []; + foreach ($data['eras'][$eraIdx]['servers'] as $srv) { + if ($original === '' || $srv['id'] !== $original) $used[$srv['id']] = true; + } + $row = [ + 'id' => '', + 'name' => $name, + 'url' => $url, + 'featured' => $featured, + 'desc' => $desc, + 'highlights' => $highlights, + 'links' => $links, + 'image' => $imgRes['image'], + ]; + $saved = false; + foreach ($data['eras'][$eraIdx]['servers'] as &$srv) { + if ($original !== '' && $srv['id'] === $original) { + $row['id'] = $srv['id']; + $srv = $row; + $saved = true; + break; + } + } + unset($srv); + if (!$saved) { + $row['id'] = collection_unique_id(collection_slug($name, 'server'), $used); + $data['eras'][$eraIdx]['servers'][] = $row; + } + if (runescape_save($data)) { + flash_set('ok', $saved ? 'Server updated.' : 'Server added.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'runescape_server_delete': { + $eraId = trim((string)($_POST['era_id'] ?? '')); + $id = trim((string)($_POST['id'] ?? '')); + $data = runescape_load(); + foreach ($data['eras'] as &$era) { + if ($era['id'] !== $eraId) continue; + $era['servers'] = array_values(array_filter($era['servers'], fn($s) => $s['id'] !== $id)); + break; + } + unset($era); + if (runescape_save($data)) { + flash_set('ok', 'Server deleted.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + case 'traffic_clear_recent': { + require_once __DIR__ . '/partials/traffic.php'; + try { + $n = traffic_clear_recent(); + flash_set('ok', 'Cleared ' . $n . ' recent hit log row(s). Page totals kept.'); + } catch (Throwable $e) { + flash_set('err', 'Could not clear traffic log: ' . $e->getMessage()); + } + break; + } + + case 'traffic_reset_all': { + require_once __DIR__ . '/partials/traffic.php'; + try { + traffic_reset_all(); + flash_set('ok', 'Reset all traffic stats and hit log.'); + } catch (Throwable $e) { + flash_set('err', 'Could not reset traffic: ' . $e->getMessage()); + } + break; + } + + case 'runescape_links_save': { + $title = trim((string)($_POST['links_title'] ?? '')) ?: 'Server Lists'; + $disclaimer = trim((string)($_POST['links_disclaimer'] ?? '')); + $items = []; + foreach (explode("\n", str_replace("\r\n", "\n", (string)($_POST['links_items'] ?? ''))) as $line) { + $line = trim($line); + if ($line === '') continue; + $parts = array_map('trim', explode('|', $line, 3)); + if (count($parts) >= 2 && $parts[0] !== '' && $parts[1] !== '' && valid_url_or_path($parts[1])) { + $items[] = ['label' => $parts[0], 'url' => $parts[1], 'desc' => $parts[2] ?? '']; + } + } + $data = runescape_load(); + $data['links_section'] = ['title' => $title, 'disclaimer' => $disclaimer, 'items' => $items]; + if (runescape_save($data)) { + flash_set('ok', 'Server Lists saved.'); + } else { + flash_set('err', 'Could not write runescape data.json (permissions?).'); + } + redirect_section('runescape'); + } + + } + redirect_section($section); +} + +/* --------------------------------------------------------------------------- + View data + --------------------------------------------------------------------------- */ +$flash = flash_get(); + +// Blog: post being edited (GET ?edit=ID) + list +$blog_edit = null; +$blog_posts = []; +$blog_err = ''; +try { + if ($section === 'blog' && isset($_GET['edit']) && ctype_digit((string)$_GET['edit'])) { + $st = blog_db()->prepare('SELECT * FROM posts WHERE id=?'); + $st->execute([(int)$_GET['edit']]); + $blog_edit = $st->fetch(PDO::FETCH_ASSOC) ?: null; + } + if ($section === 'blog') { + $blog_posts = blog_db()->query('SELECT * FROM posts ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC); + } +} catch (Throwable $e) { + $blog_err = $e->getMessage(); +} + +// Changelog files +$changelog_files = []; +if ($section === 'changelog') { + foreach (glob(CHANGELOG_DIR . '/*-changelog.txt') ?: [] as $f) $changelog_files[] = $f; + rsort($changelog_files); +} + +// Vibe current +$vibe = is_file(VIBE_FILE) ? (require VIBE_FILE) : ['description' => '', 'url' => '', 'updated' => '']; +$vibe_t = vibe_type($vibe['url'] ?? ''); +$vibe_embed = vibe_embed_url($vibe['url'] ?? ''); + +// Now page: current data flattened back to the textarea format +$now_data = is_file(NOW_FILE) ? (require NOW_FILE) : ['updated' => '', 'sections' => []]; +$now_text = ''; +foreach (($now_data['sections'] ?? []) as $sec) { + $now_text .= '# ' . ($sec['title'] ?? '') . "\n"; + foreach (($sec['items'] ?? []) as $item) $now_text .= $item . "\n"; + $now_text .= "\n"; +} +$now_text = rtrim($now_text) . "\n"; + +$motd = $section === 'motd' ? motd_load() : motd_default(); +$motd_preview = motd_xmpp_text($motd); + +// Guestbook messages +$gb_rows = []; +$gb_err = ''; +$gb_q = trim($_GET['q'] ?? ''); +if ($section === 'guestbook') { + $db = guestbook_db(); + if (!$db) { + $gb_err = 'Guestbook database is unreachable.'; + } else { + try { + if ($gb_q !== '') { + $st = $db->prepare('SELECT * FROM messages WHERE CAST(id AS CHAR)=:eid OR name LIKE :q OR message LIKE :q ORDER BY created_at DESC'); + $st->execute([':eid' => $gb_q, ':q' => '%' . $gb_q . '%']); + } else { + $st = $db->query('SELECT * FROM messages ORDER BY created_at DESC'); + } + $gb_rows = $st->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + $gb_err = $e->getMessage(); + } + } +} + +$journal_rows = journal_entries(); +$journal_edit = null; +if ($section === 'journal' && isset($_GET['journal_edit'])) { + $edit_date = trim($_GET['journal_edit']); + foreach ($journal_rows as $row) { + if ($row['date'] === $edit_date) { $journal_edit = $row; break; } + } +} +$startpage_text = startpage_cards_text(); + +$collection = collection_load(); +$collection_vg_edit = null; +$collection_hw_edit = null; +$collection_sys_edit = null; +$collection_game_edit = null; +$collection_game_system = ''; +$collection_online_cat_edit = null; +$collection_online_entry_edit = null; +$collection_online_entry_cat = ''; +if ($section === 'collection') { + if (isset($_GET['vg_edit']) || isset($_GET['console_edit'])) { + $vid = trim((string)($_GET['console_edit'] ?? $_GET['vg_edit'] ?? '')); + foreach ($collection['consoles'] as $item) { + if ($item['id'] === $vid) { $collection_vg_edit = $item; break; } + } + } + if (isset($_GET['hw_edit'])) { + $hid = trim((string)$_GET['hw_edit']); + foreach ($collection['hardware'] as $item) { + if ($item['id'] === $hid) { $collection_hw_edit = $item; break; } + } + } + if (isset($_GET['sys_edit'])) { + $sid = trim((string)$_GET['sys_edit']); + foreach ($collection['systems'] as $sys) { + if ($sys['id'] === $sid) { $collection_sys_edit = $sys; break; } + } + } + if (isset($_GET['game_edit'], $_GET['system'])) { + $gid = trim((string)$_GET['game_edit']); + $sid = trim((string)$_GET['system']); + foreach ($collection['systems'] as $sys) { + if ($sys['id'] !== $sid) continue; + foreach ($sys['games'] as $game) { + if ($game['id'] === $gid) { + $collection_game_edit = $game; + $collection_game_system = $sid; + break 2; + } + } + } + } + if (isset($_GET['online_cat_edit'])) { + $cid = trim((string)$_GET['online_cat_edit']); + foreach ($collection['online'] as $cat) { + if ($cat['id'] === $cid) { $collection_online_cat_edit = $cat; break; } + } + } + if (isset($_GET['online_entry_edit'], $_GET['category'])) { + $eid = trim((string)$_GET['online_entry_edit']); + $cid = trim((string)$_GET['category']); + foreach ($collection['online'] as $cat) { + if ($cat['id'] !== $cid) continue; + foreach ($cat['entries'] as $entry) { + if ($entry['id'] === $eid) { + $collection_online_entry_edit = $entry; + $collection_online_entry_cat = $cid; + break 2; + } + } + } + } +} + +$runescape = runescape_load(); +$runescape_welcome_text = implode("\n", $runescape['welcome']); +$rs_era_edit = null; +$rs_server_edit = null; +$rs_server_era = ''; +if ($section === 'runescape') { + if (isset($_GET['era_edit'])) { + $reid = trim((string)$_GET['era_edit']); + foreach ($runescape['eras'] as $era) { + if ($era['id'] === $reid) { $rs_era_edit = $era; break; } + } + } + if (isset($_GET['server_edit'], $_GET['era'])) { + $sid = trim((string)$_GET['server_edit']); + $reid = trim((string)$_GET['era']); + foreach ($runescape['eras'] as $era) { + if ($era['id'] !== $reid) continue; + foreach ($era['servers'] as $srv) { + if ($srv['id'] === $sid) { + $rs_server_edit = $srv; + $rs_server_era = $reid; + break 2; + } + } + } + } +} +$rs_server_era_default = $rs_server_era !== '' ? $rs_server_era : ($runescape['eras'][0]['id'] ?? ''); + +// Radio stations (radio.sillylaird.ca) +$radio_stations = $section === 'radio' ? radio_load() : []; +$radio_edit = null; +if ($section === 'radio' && isset($_GET['radio_edit'])) { + $reid = trim((string)$_GET['radio_edit']); + foreach ($radio_stations as $s) { + if ($s['id'] === $reid) { $radio_edit = $s; break; } + } +} + +// Traffic (privacy-friendly hit log — no IPs) +$traffic_summary = null; +$traffic_top = []; +$traffic_recent = []; +$traffic_analytics = null; +$traffic_err = null; +if ($section === 'traffic') { + require_once __DIR__ . '/partials/traffic.php'; + try { + $traffic_summary = traffic_summary(); + $traffic_top = traffic_top_pages(80); + $traffic_recent = traffic_recent(150); + $traffic_analytics = traffic_analytics(); + } catch (Throwable $e) { + $traffic_err = $e->getMessage(); + } +} + +$tabs = ['blog' => 'Blog', 'changelog' => 'Changelog', 'vibe' => 'Vibe', 'now' => 'Now', 'motd' => 'MOTD', 'startpage' => 'StartPage', 'journal' => 'Journal', 'guestbook' => 'Guestbook', 'collection' => 'Collection', 'runescape' => 'RuneScape', 'radio' => 'Radio', 'traffic' => 'Traffic']; +?> + + + + + + + Admin — SillyLaird + + + + + + + + + + +
+
+

Admin

+

Unified control panel — logout

+ + + + +
+ + + + +
Blog DB error:
+ + +
+
+
+ + + +
+
+
+
+ + +
+
+ + + +
+ +
+
+
+
+ +
+
Live preview
+
Type to preview…
+
+
+ +
+
Posts ()
+
+ +

No posts yet.

+ +
    + +
  • +
    +
    + View · + Edit · +
    + + + + +
    +
  • + +
+ +
+
+ + + +
+
Add entry
+
+
+ + +
+ + +
+
+ + +
+ +
+
+
+
+
Files
+
+
    + +
  • ( bytes)
  • + +
  • No changelog files.
  • +
+
+
+ + + +
+
+
+ + +
+
Config
+
+
+ + +
+
+ + +

detected:

+
+

last saved:

+ +
+
+
+
+
+
Preview
+
+ +

// no url set

+ + vibe preview + + + + + +
+ +
+

· open ↗

+ +

+ +
+
+
+ + + +
+
Edit /now/ page
+
+
+ + +
+ + +
+

last saved:

+ + View /now/ ↗ +
+
+
+ + + +

Edit the bot line (emoji, heading, body, URL). Save updates /motd/. waifu only posts to the room when you click Announce now.

+
+
+
+ + +
+
Edit MOTD
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

last saved: · rev

+ + View /motd/ ↗ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
XMPP preview
+
+
+

This is exactly what waifu will say when you click Announce now.

+
+
+
+ +
+
History ()
+
+
    + +
  • + +
    + 180 ? substr($past['body'], 0, 180) . '...' : $past['body']) ?> +
  • + +
+
+
+ + + + +
+
Edit StartPage cards
+
+
+ + +
+ + +
+ + View StartPage +
+
+
+ + + +
+
+
+
+
+
+ + + +
+ + +
+
+ + +
+ + Cancel + View Journal +
+
+
+
+ +
+
+
Journal entries ()
+
+
    + +
  • +
    + 180 ? substr($entry['body'], 0, 180) . '...' : $entry['body']; ?> +
    + Edit + · +
    + + + + +
    +
  • + +
+
+
+
+
+ + + +
+
+ + + + Clear +
+ + +
+ + +
+ + message(s) +
+ + + + + + + + + + + + +
IDNameMessageTime
No messages.
+
+ + + + + + + +

+ Catalog data: gaming/collection/games.json · photos: gaming/collection/images/ + · Video Games + / Consoles + / Hardware + / Song Packs + / Wishlist +

+ + +
+
Live catalog preview (click Edit to change left photo / fields)
+
+

Game Collection

+ + +

Video Games (titles)

+ + +

No video game titles yet — add a system and title below.

+ +
+ +
+
+ + + +
no photo
+ +
+
+

::

+

+ +
+ Edit photo / fields + · +

+ + + + + +
+

+
+
+ +
+ + +

Consoles

+ +

No consoles yet — add one below.

+ +
+ +
+
+ + + +
no photo
+ +
+
+

::

+

+ + + — 80 ? substr($vg['notes'], 0, 80) . '…' : $vg['notes']) ?> + +
+ Edit photo / fields + · +

+ + + + +
+

+
+
+ +
+ + +

Hardware

+ +

No hardware yet — add items below.

+ +
+ +
+
+ + + +
no photo
+ +
+
+

::

+

+ +
+ Edit photo / fields + · +

+ + + + +
+

+
+
+ +
+ + +

Song Packs

+ + +

No song packs yet — add a game category and pack below.

+ +
+ +
+
+ + + +
no photo
+ +
+
+

::

+

+ + + · store + +
+ Edit photo / fields + · +

+ + + + + +
+

+
+
+ +
+ +
+
+ +

Edit Video Games (titles)

+

Titles under a system — shown as System :: Title on the public Video Games page.

+
+
+
+
+
+
+ + + +
+ + +
+ + + Cancel + +
+
+
+ +
+
+
+ +

Add a system first.

+ +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Current photo + + + +

JPEG / PNG / GIF / WebP · max 2 MB · saved under images/

+ + + + + +
+ + + Cancel + +
+ +
+
+
+ +
+
+
Game systems ()
+
+ +

No systems yet.

+ +
    + +
  • + + ( title(s))
    + Rename + · +
    + + + + +
    +
  • + +
+ +
+
+
+
+ +

Edit Consoles

+

Consoles as inventory — Name :: Model on the Consoles page.

+
+
+
+
+
+
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
+ + Cancel +
+
+
+
+
+
+
Consoles ()
+
+

None yet.

+
    + +

  • +
    + Edit · +
    + + +
    +
  • + +
+
+
+
+
+ +

Edit Hardware

+

Cabinets, controllers, PCs — Name :: Model on the Hardware page.

+
+
+
+
+
+
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
+ + Cancel +
+
+
+
+
+
+
Hardware ()
+
+

None yet.

+
    + +

  • +
    + Edit · +
    + + +
    +
  • + +
+
+
+
+
+ +

Edit Song Packs

+
+
+
+
+
+
+ + + +
+ + +
+ + + Cancel + +
+
+
+ +
+
+
+ +

Add a game category first (e.g. Pop'n Music Lively).

+ +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Current photo + + + +

JPEG / PNG / GIF / WebP · max 2 MB

+ + + + + +
+ + + Cancel + +
+ +
+
+
+ +
+
+
Pack games ()
+
+ +

No pack games yet.

+ +
    + +
  • + + ( pack(s))
    + Rename + · +
    + + + + +
    +
  • + +
+ +
+
+
+
+ + + +
+
+
+
Edit Welcome list
+
+
+ + +
+ + +
+ + View page ↗ +
+
+
+
+
+
+
Add bank / inventory screenshot
+
+
+ + +
+ + +
+
+ + +
+ +
+
+
+ +
+
Screenshots ()
+
+

None yet — the public page shows placeholder boxes until you add some.

+
    + +
  • +
    +
    +
    + + +
    +
  • + +
+
+
+
+
+ +

Edit Eras

+

Groups like "2004 · Lost City Era" or "Other Era Servers" — each holds one or more servers below.

+
+
+
+
+
+
+ + + +
+ + +
+
+ + +
+
+ + + + + + + + +
+ + Cancel +
+
+
+
+
+
+
Eras ()
+
+

None yet.

+
    + + +
  • +
    + server
    + Edit · +
    + + +
    +
  • + +
+
+
+
+
+ +

Edit Servers

+

One entry per server card — add an era above first if you need a new group.

+
+
+
+
+
+ +

Add an era first.

+ +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+ + Cancel +
+ +
+
+
+
+ +
+
()
+
+

None yet.

+
    + + +
  • +
    + Featured' : '' ?>
    + Edit · +
    + + +
    +
  • + +
+
+
+ +
+
+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + +

Stations for radio.sillylaird.ca — the list order here is the order on the page. Plain http:// streams need Relay ticked, otherwise browsers block them as mixed content on the HTTPS page.

+ +
+
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ + · cancel +
+
+
+
+ +
+
Now-playing API types
+
+
    +
  • icecast — any Icecast status-json.xsl
  • +
  • radio — r/a/dio style /api
  • +
  • plaza — plaza.one /status
  • +
  • gensokyo — Gensokyo Radio playing API
  • +
  • somafm — somafm.com/songs/<channel>.json
  • +
+

Leave the type empty and the player just shows the station name. Lookups are proxied by /meta on radio and cached 10s.

+
+
+
+ +
+
Stations ()
+
+ +

No stations yet.

+ +
    + +
  • + + — + [relay] + [] +
    +
    + Edit · +
    + + + + + +
    +
    + + + + + +
    + · +
    + + + + +
    +
  • + +
+ +
+
+ + +

Privacy-friendly traffic log for all sillylaird.ca pages. Stores host, path, coarse browser · OS label, language, referrer host, and the visitor’s local hit number. No IP addresses are stored. Bots are counted separately and never inflate human hit / session totals.

+ + +
Traffic DB error:
+ +
+
+ + Live + · connecting… +
+ +
+
+ + Human hits +
+
+ + Unique sessions +
+
+ + Humans · 24h +
+
+ + Humans · 7 days +
+
+ + Bot hits (all time) +
+
+ + Bots · 24h +
+
+ + Bots · 7 days +
+
+ + Pages seen +
+
+ + +
+ + +
+
Hits — last 30 days (UTC)
+
+ + +
+
+ +
+
Hits — last 24 hours (UTC)
+
+ + +
+
+ +
+
+
Top pages (humans)
+
+ +
+
+
+
Visitors (browser · OS) — 7 days (humans only)
+
+ +
+
+
+ +
+
Bots — 7 days (not counted as visitors)
+
+ +
+
+ +
+
+
Hosts — 7 days
+
+ +
+
+
+
Languages — 7 days
+
+ +
+
+
+ +
+
Referrers — 7 days
+
+ +
+
+
+ + +
+
+
Maintenance
+
+

Hit detail rows auto-prune after days. Page totals are kept until you reset them. Charts use the hit log (not lifetime page totals).

+
+ + + +
+
+ + + +
+
+
+
+ +
+
Top pages (by hits)
+
+ +

No hits recorded yet. Browse a page and refresh.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PageHuman hitsUniqBotsLocal #Last visitorLast hit
+ +
+ +
+
+ +
+
Recent hits (visitor log)
+
+ +

No recent hits.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
When (UTC)VisitorTypePageLocal #LangFromNew?
bot' : 'human' ?>
+ +
+
+
+ + + +
+
+ + + + + + + -- cgit v1.2.3