/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.

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 Hardware

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

Cancel
Hardware ()

None yet.

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.

Page Human hits Uniq Bots Local # Last visitor Last hit
Recent hits (visitor log)

No recent hits.

When (UTC) Visitor Type Page Local # Lang From New?
bot' : 'human' ?>