diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /partials | |
| download | www-main.tar.gz www-main.zip | |
Diffstat (limited to '')
| -rw-r--r-- | partials/admin_vibe.php | 201 | ||||
| -rw-r--r-- | partials/asset_version.php | 21 | ||||
| -rw-r--r-- | partials/footer.php | 40 | ||||
| -rw-r--r-- | partials/head_meta.php | 103 | ||||
| -rw-r--r-- | partials/header.php | 177 | ||||
| -rw-r--r-- | partials/header_search.php | 34 | ||||
| -rw-r--r-- | partials/i18n.php | 40 | ||||
| -rw-r--r-- | partials/partials_vibe.php | 212 | ||||
| -rw-r--r-- | partials/proxy_helpers.php | 80 | ||||
| -rw-r--r-- | partials/recent_static.php | 195 | ||||
| -rw-r--r-- | partials/session.php | 15 | ||||
| -rw-r--r-- | partials/site_nav_panel.php | 44 | ||||
| -rw-r--r-- | partials/site_status.php | 70 | ||||
| -rw-r--r-- | partials/traffic.php | 626 | ||||
| -rw-r--r-- | partials/vibe.php | 73 | ||||
| -rw-r--r-- | partials/vibe_config.php | 9 |
16 files changed, 1940 insertions, 0 deletions
diff --git a/partials/admin_vibe.php b/partials/admin_vibe.php new file mode 100644 index 0000000..74227c3 --- /dev/null +++ b/partials/admin_vibe.php @@ -0,0 +1,201 @@ +<?php +// admin/vibe.php — admin editor for "My Current Vibe" section +// Requires an active session with $_SESSION['admin'] === true (your existing auth). + +require_once __DIR__ . '/session.php'; + +if (empty($_SESSION['admin'])) { + header('Location: /admin/login.php'); + exit; +} + +$vibe_file = __DIR__ . '/../vibe.php'; + +// Load existing config +$vibe = file_exists($vibe_file) + ? require $vibe_file + : ['description' => '', 'url' => '', 'updated' => '']; + +$errors = []; +$success = false; + +// Handle POST save +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $desc = trim($_POST['description'] ?? ''); + $url = trim($_POST['url'] ?? ''); + $updated = date('Y-m-d'); + + // Basic URL validation (allow empty) + if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = 'URL does not appear to be valid.'; + } + + if (empty($errors)) { + $export = var_export([ + 'description' => $desc, + 'url' => $url, + 'updated' => $updated, + ], true); + + $content = "<?php\n// vibe.php — current vibe config, edited via admin/vibe.php\n// Do not edit manually unless you know what you're doing.\n\nreturn {$export};\n"; + + if (file_put_contents($vibe_file, $content) !== false) { + $success = true; + $vibe = ['description' => $desc, 'url' => $url, 'updated' => $updated]; + } else { + $errors[] = 'Could not write vibe.php — check file permissions.'; + } + } +} + +// Detect embed type for preview +function admin_vibe_detect_type(string $url): string { + if (empty($url)) return 'none'; + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ''); + $ext = pathinfo($path, PATHINFO_EXTENSION); + $images = ['jpg','jpeg','png','gif','webp','bmp','svg','avif']; + $videos = ['mp4','webm','ogv','mov']; + $audio = ['mp3','flac','wav','ogg','aac','opus','m4a']; + if (in_array($ext, $images)) return 'image'; + if (in_array($ext, $videos)) return 'video'; + if (in_array($ext, $audio)) return 'audio'; + if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $url, $m)) return 'youtube:' . $m[1]; + if (preg_match('/vimeo\.com\/(\d+)/', $url, $m)) return 'vimeo:' . $m[1]; + return 'link'; +} + +$preview_type = admin_vibe_detect_type($vibe['url'] ?? ''); +$safe_url = htmlspecialchars($vibe['url'] ?? '', ENT_QUOTES, 'UTF-8'); + +// Human-readable label for detected type +function type_label(string $type): string { + if ($type === 'none') return '—'; + if ($type === 'image') return 'Image'; + if ($type === 'video') return 'Video'; + if ($type === 'audio') return 'Audio'; + if ($type === 'link') return 'Plain link'; + if (str_starts_with($type, 'youtube:')) return 'YouTube embed'; + if (str_starts_with($type, 'vimeo:')) return 'Vimeo embed'; + return 'Unknown'; +} +?> +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Edit Current Vibe — SillyLaird</title> + <link rel="stylesheet" href="/assets/css/fonts.css" /> + <link rel="stylesheet" href="/assets/css/site.css?v=1.5" /> + <style> + .admin-wrap { max-width: 640px; margin: 2rem auto; padding: 0 1rem; } + .field { margin-bottom: 1.25rem; } + .field label { display: block; margin-bottom: 0.35rem; font-weight: bold; font-size: 0.9em; } + .field input, + .field textarea { width: 100%; box-sizing: border-box; font-family: inherit; font-size: 1rem; padding: 0.45rem 0.6rem; border: 1px solid #555; background: #111; color: #eee; border-radius: 3px; } + .field textarea { resize: vertical; min-height: 80px; } + .detect-badge { display: inline-block; margin-top: 0.4rem; font-size: 0.8em; padding: 2px 8px; border: 1px solid #555; border-radius: 3px; color: #aaa; } + .btn-save { padding: 0.5rem 1.4rem; font-size: 1rem; cursor: pointer; } + .notice-ok { color: #7f7; border: 1px solid #484; padding: 0.5rem 0.8rem; border-radius: 3px; margin-bottom: 1rem; } + .notice-err { color: #f77; border: 1px solid #844; padding: 0.5rem 0.8rem; border-radius: 3px; margin-bottom: 1rem; } + .preview-box { margin-top: 1.5rem; border-top: 1px solid #333; padding-top: 1rem; } + .preview-box h3 { margin-bottom: 0.75rem; font-size: 0.95em; color: #aaa; text-transform: uppercase; letter-spacing: 0.05em; } + .preview-box img, + .preview-box video, + .preview-box audio { max-width: 100%; display: block; margin-top: 0.5rem; } + .preview-box iframe { width: 100%; border: 0; display: block; margin-top: 0.5rem; } + .back-link { font-size: 0.85em; margin-bottom: 1.5rem; display: inline-block; } + </style> +</head> +<body> +<div class="admin-wrap"> + <a class="back-link" href="/admin/">← Back to admin</a> + <h1>Edit: My Current Vibe</h1> + + <?php if ($success): ?> + <p class="notice-ok">✓ Saved successfully.</p> + <?php endif; ?> + + <?php if (!empty($errors)): ?> + <div class="notice-err"> + <?php foreach ($errors as $e): ?> + <p><?= htmlspecialchars($e, ENT_QUOTES, 'UTF-8') ?></p> + <?php endforeach; ?> + </div> + <?php endif; ?> + + <form method="post" action=""> + <div class="field"> + <label for="description">Description / caption</label> + <textarea id="description" name="description" rows="3"><?= htmlspecialchars($vibe['description'] ?? '', ENT_QUOTES, 'UTF-8') ?></textarea> + </div> + + <div class="field"> + <label for="url">URL (image, video, audio, YouTube, Vimeo, or plain link)</label> + <input + type="url" + id="url" + name="url" + value="<?= $safe_url ?>" + placeholder="https://uploads.sillylaird.ca/..." + autocomplete="off" + /> + <span class="detect-badge"> + Detected type: <strong><?= type_label($preview_type) ?></strong> + </span> + </div> + + <?php if ($vibe['updated'] ?? ''): ?> + <p class="muted" style="font-size:0.85em;">Last saved: <?= htmlspecialchars($vibe['updated'], ENT_QUOTES, 'UTF-8') ?></p> + <?php endif; ?> + + <button class="btn-save" type="submit">Save Vibe</button> + </form> + + <?php if (!empty($vibe['url'])): ?> + <div class="preview-box"> + <h3>Current Preview</h3> + + <?php if ($preview_type === 'image'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <img src="<?= $safe_url ?>" alt="Vibe preview" loading="lazy"> + + <?php elseif ($preview_type === 'video'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <video controls preload="none" style="max-width:100%;"> + <source src="<?= $safe_url ?>"> + </video> + + <?php elseif ($preview_type === 'audio'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <audio controls preload="none" style="width:100%;"> + <source src="<?= $safe_url ?>"> + </audio> + + <?php elseif (str_starts_with($preview_type, 'youtube:')): ?> + <?php $yt_id = substr($preview_type, 8); ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <div style="position:relative;padding-top:56.25%;"> + <iframe src="https://www.youtube.com/embed/<?= htmlspecialchars($yt_id) ?>" + style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" + allowfullscreen loading="lazy"></iframe> + </div> + + <?php elseif (str_starts_with($preview_type, 'vimeo:')): ?> + <?php $vimeo_id = substr($preview_type, 6); ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <div style="position:relative;padding-top:56.25%;"> + <iframe src="https://player.vimeo.com/video/<?= htmlspecialchars($vimeo_id) ?>" + style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" + allowfullscreen loading="lazy"></iframe> + </div> + + <?php else: ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <?php endif; ?> + </div> + <?php endif; ?> + +</div> +</body> +</html> diff --git a/partials/asset_version.php b/partials/asset_version.php new file mode 100644 index 0000000..ff5204f --- /dev/null +++ b/partials/asset_version.php @@ -0,0 +1,21 @@ +<?php +// partials/asset_version.php — cache-bust helper for static assets. +// +// Usage: +// require_once __DIR__ . '/partials/asset_version.php'; // adjust path +// Then: htmlspecialchars(asset_v('assets/css/site.css'), ENT_QUOTES) +// inside an href/src attribute. +// +// Paths are relative to the site document root (parent of partials/). + +if (!function_exists('asset_v')) { + function asset_v(string $rel): string { + static $root = null; + if ($root === null) { + $root = dirname(__DIR__); + } + $abs = $root . '/' . ltrim($rel, '/'); + $mt = @filemtime($abs); + return $rel . ($mt ? ('?v=' . $mt) : ''); + } +} diff --git a/partials/footer.php b/partials/footer.php new file mode 100644 index 0000000..e519306 --- /dev/null +++ b/partials/footer.php @@ -0,0 +1,40 @@ +<?php + // "Explore SillyLaird" side panel retired — it fought dense layouts on www + // and subdomains (blog, startpage, etc.). Site map + header nav cover this. +?> +<footer class="site-footer"> + <div class="footer-inner"> + <p class="footer-back-to-top"><a href="#top"><?php + echo htmlspecialchars(function_exists('t') ? t('common.top') : '↑ Top', ENT_QUOTES, 'UTF-8'); + ?></a></p> + <div>© <span id="copyright-year">2026</span> SillyLaird — Licensed under <a href="/wtfpl.txt">WTFPL</a></div> + <?php + require_once __DIR__ . '/site_status.php'; + render_site_update_status('site-status-row footer-status-row'); + ?> + <div>Privacy: Some pages load third-party content (Last.fm, guestbook, blog, embeds).</div> + + <div class="footer-joke">Best viewed with two eyes, in any browser, on any network. 🕸️</div> + + <p class="site-visit-counter" id="site-visit-counter" aria-live="polite"> + <span class="site-visit-counter-label">You are visitor</span> + <span class="site-visit-counter-digits" id="site-visit-digits">------</span> + </p> + + <p class="footer-smallweb"><?php + $madeWith = function_exists('t') ? t('home.made_with') : 'Made with ♥ on the small web'; + $madeWith = htmlspecialchars($madeWith, ENT_QUOTES, 'UTF-8'); + echo str_replace('♥', '<span class="footer-heart" aria-hidden="true">♥</span>', $madeWith); + ?></p> + <p class="footer-colophon">Colophon: hand-built with PHP, nginx, and an Emacs-shaped workflow. No surveillance analytics — just a small visitor counter.</p> + + <div class="footer-badges"> + <a class="badge own-button" href="https://www.sillylaird.ca/" title="Link back to SillyLaird — grab the button!"> + <img src="/assets/img/sillylaird-88x31.png" width="88" height="31" alt="SillyLaird — 88x31 link button" loading="lazy" decoding="async"> + </a> + <a class="badge eff-badge" href="https://www.eff.org/issues/free-speech" target="_blank" rel="noopener noreferrer"> + <img src="/assets/img/rib_bar_red.png" width="150" height="31" alt="EFF free speech badge" loading="lazy" decoding="async" class="s9572e08b37"> + </a> + </div> + </div> +</footer> diff --git a/partials/head_meta.php b/partials/head_meta.php new file mode 100644 index 0000000..4f1bbd4 --- /dev/null +++ b/partials/head_meta.php @@ -0,0 +1,103 @@ +<?php +// partials/head_meta.php — render the OG / Twitter / canonical / hreflang +// boilerplate that every triplicated page needs. +// +// Caller passes in: +// $meta = [ +// 'lang' => 'en'|'ja'|'zh', // current locale +// 'title' => 'Page title', // og/twitter title +// 'desc' => 'Page description', // og/twitter description +// 'base' => 'Laird_Lonergan_profile', // filename without lang suffix or .php +// 'image' => 'https://www.sillylaird.ca/...', // optional, default og-default.png +// 'image_alt' => 'alt text for the OG image', // optional +// 'image_width' => 1200, 'image_height' => 630, // optional, override defaults +// 'twitter_desc' => '...', // optional, defaults to $desc +// 'type' => 'website'|'article'|'profile', // optional, default website +// 'author' => 'Laird Lonergan', // optional, for articles +// 'published' => '2026-05-26', // optional ISO date for articles +// 'updated' => '2026-05-26', // optional ISO date +// 'og_url' => 'https://www.sillylaird.ca/', // optional; defaults to self_url +// ] + +$_lang = $meta['lang'] ?? 'en'; +$_title = $meta['title'] ?? ''; +$_desc = $meta['desc'] ?? ''; +$_base = $meta['base'] ?? 'index'; +$_image = $meta['image'] ?? 'https://www.sillylaird.ca/assets/img/og-default.png'; +$_imgW = (int)($meta['image_width'] ?? 1200); +$_imgH = (int)($meta['image_height'] ?? 630); +$_imgAlt = $meta['image_alt'] ?? ($_title !== '' ? $_title : 'SillyLaird'); +$_twDesc = $meta['twitter_desc'] ?? $_desc; +$_type = $meta['type'] ?? 'website'; +$_author = $meta['author'] ?? ''; +$_published= $meta['published'] ?? ''; +$_updated = $meta['updated'] ?? ''; + +$_suffix = ['en' => '', 'ja' => '_jp', 'zh' => '_zh'][$_lang] ?? ''; +$_self = '/' . $_base . $_suffix . '.php'; +// URL overrides let pages with non-default canonicals (e.g. the homepage, +// whose EN canonical is the root "/" not "/index.php") reuse this partial. +$_selfUrl = $meta['self_url'] ?? ('https://www.sillylaird.ca' . $_self); +$_ogUrl = $meta['og_url'] ?? $_selfUrl; +$_enUrl = $meta['en_url'] ?? ('https://www.sillylaird.ca/' . $_base . '.php'); +$_jaUrl = $meta['ja_url'] ?? ('https://www.sillylaird.ca/' . $_base . '_jp.php'); +$_zhUrl = $meta['zh_url'] ?? ('https://www.sillylaird.ca/' . $_base . '_zh.php'); +$_xDefault = $meta['x_default'] ?? $_enUrl; +$_ogLocale = ['en' => 'en_US', 'ja' => 'ja_JP', 'zh' => 'zh_CN'][$_lang]; + +$_h = fn(string $s) => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); +?> + <meta name="description" content="<?= $_h($_desc) ?>" /> + <meta name="author" content="<?= $_h($_author !== '' ? $_author : 'Laird Lonergan (SillyLaird)') ?>" /> + <meta name="theme-color" content="#000000" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-title" content="SillyLaird" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="referrer" content="strict-origin-when-cross-origin" /> + + <meta property="og:site_name" content="SillyLaird" /> + <meta property="og:title" content="<?= $_h($_title) ?>" /> + <meta property="og:description" content="<?= $_h($_desc) ?>" /> + <meta property="og:image" content="<?= $_h($_image) ?>" /> + <meta property="og:image:width" content="<?= $_imgW ?>" /> + <meta property="og:image:height" content="<?= $_imgH ?>" /> + <meta property="og:image:alt" content="<?= $_h($_imgAlt) ?>" /> + <meta property="og:type" content="<?= $_h($_type) ?>" /> + <meta property="og:url" content="<?= $_h($_ogUrl) ?>" /> + <meta property="og:locale" content="<?= $_ogLocale ?>" /> +<?php foreach (['en_US', 'ja_JP', 'zh_CN'] as $_alt): if ($_alt !== $_ogLocale): ?> + <meta property="og:locale:alternate" content="<?= $_alt ?>" /> +<?php endif; endforeach; ?> +<?php if ($_type === 'article'): ?> +<?php if ($_published): ?> + <meta property="article:published_time" content="<?= $_h($_published) ?>" /> +<?php endif; ?> +<?php if ($_updated): ?> + <meta property="article:modified_time" content="<?= $_h($_updated) ?>" /> +<?php endif; ?> +<?php if ($_author): ?> + <meta property="article:author" content="<?= $_h($_author) ?>" /> +<?php endif; ?> +<?php endif; ?> + + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:title" content="<?= $_h($_title) ?>" /> + <meta name="twitter:description" content="<?= $_h($_twDesc) ?>" /> + <meta name="twitter:image" content="<?= $_h($_image) ?>" /> + <meta name="twitter:image:alt" content="<?= $_h($_imgAlt) ?>" /> + + <link rel="canonical" href="<?= $_h($_selfUrl) ?>" /> + <link rel="alternate" hreflang="x-default" href="<?= $_h($_xDefault) ?>" /> + <link rel="alternate" hreflang="en" href="<?= $_h($_enUrl) ?>" /> + <link rel="alternate" hreflang="zh" href="<?= $_h($_zhUrl) ?>" /> + <link rel="alternate" hreflang="ja" href="<?= $_h($_jaUrl) ?>" /> + + <link rel="alternate" type="application/rss+xml" title="SillyLaird Blog (RSS)" href="https://blog.sillylaird.ca/feed.xml.php" /> + <link rel="alternate" type="application/atom+xml" title="SillyLaird Changelog (Atom)" href="https://www.sillylaird.ca/changelog/feed.xml.php" /> + <link rel="alternate" type="application/atom+xml" title="SillyLaird Journal (Atom)" href="https://www.sillylaird.ca/journal/feed.xml.php" /> + + <link rel="icon" type="image/x-icon" href="/favicon.ico" /> + <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> + <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> + <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> + <link rel="manifest" href="/site.webmanifest" /> diff --git a/partials/header.php b/partials/header.php new file mode 100644 index 0000000..a9a8e88 --- /dev/null +++ b/partials/header.php @@ -0,0 +1,177 @@ +<?php +$_file = basename($_SERVER['PHP_SELF'] ?? 'index.php'); +if (str_ends_with($_file, '_zh.php')) { + $_cur_lang = 'ZH'; +} elseif (str_ends_with($_file, '_jp.php')) { + $_cur_lang = 'JP'; +} else { + $_cur_lang = 'EN'; +} +$_sjs = @filemtime(__DIR__ . '/../assets/js/site.js'); +$_is_startpage = str_contains($_SERVER['SCRIPT_NAME'] ?? '', '/startpage/'); +?> +<script defer src="/assets/js/site.js<?= $_sjs ? '?v=' . $_sjs : '' ?>"></script> +<span id="top"></span> +<header class="site-header"> + <div class="header-inner"> + <div class="brand"> + <a href="/" + class="logo" + aria-label="SillyLaird home"> + <img src="/assets/img/lain.png" + alt="SillyLaird logo" + width="48" + height="48" + decoding="async" /> + </a> + <div class="brand-text"> + <strong>SillyLaird</strong> + <span>personal hub: links, logs, and vibes</span> + </div> + </div> + + <div class="header-controls"> + <div class="protocol-pills" aria-label="Alternate network protocols"> + <a class="protocol-pill protocol-pill--gemini" + href="gemini://sillylaird.ca" + target="_blank" + rel="noopener noreferrer" + title="Open the Gemini capsule">Gemini</a> + <a class="protocol-pill protocol-pill--gopher" + href="gopher://sillylaird.ca" + target="_blank" + rel="noopener noreferrer" + title="Open the Gopher hole">Gopher</a> + <a class="protocol-pill protocol-pill--tor" + href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion/" + title="Open the Tor onion service">Tor</a> + <a class="protocol-pill protocol-pill--i2p" + href="http://sillylaird.i2p/" + title="Open the I2P site">I2P</a> + </div> + + <a href="/startpage/" class="header-button header-button--startpage" title="Open the power-user StartPage">StartPage</a> + + <a href="/" class="header-button" title="Go to the homepage">Home</a> + + <button class="header-button header-icon-button comfort-toggle" + id="comfort-toggle" + type="button" + title="Comfort spacing: OFF" + aria-pressed="false" + aria-label="Comfort spacing off">↕</button> + + <button class="header-button header-icon-button" + id="theme-toggle" + type="button" + title="Toggle light / dark colour theme" + aria-label="Switch to light theme">☀</button> + + <div class="lang"> + <div class="lang-toggle-wrapper"> + <button class="header-button header-icon-button" + id="lang-toggle" + type="button" + title="Choose language" + aria-haspopup="menu" + aria-expanded="false"> + 文<span class="visually-hidden">Language: <span id="current-lang"><?= $_cur_lang ?></span></span> + </button> + </div> + + <div class="lang-menu" + id="lang-options" + role="menu" + hidden> + <a role="menuitem" + href="?lang=en" + hreflang="en" + lang="en" + data-lang="en" + data-lang-href>English</a> + <a role="menuitem" + href="?lang=zh" + hreflang="zh" + lang="zh" + data-lang="zh" + data-lang-href>中文</a> + <a role="menuitem" + href="?lang=jp" + hreflang="ja" + lang="ja" + data-lang="jp" + data-lang-href>日本語</a> + </div> + </div> + + <div class="menu-wrapper"> + <button id="menu-toggle" + type="button" + aria-label="Menu" + aria-controls="site-nav" + aria-expanded="false">☰</button> + + <nav id="site-nav" + class="site-nav" + aria-label="Main navigation" + hidden> + <ul class="nav-section" aria-label="Main"> + <li class="nav-label">Main</li> + <li><a href="https://www.sillylaird.ca/">Home</a></li> + <li><a href="https://os.sillylaird.ca/" target="_blank" rel="noopener noreferrer">OS</a></li> + <li><a href="https://www.sillylaird.ca/now/">Now</a></li> + <li><a href="https://www.sillylaird.ca/motd/">MOTD</a></li> + <li><a href="https://www.sillylaird.ca/startpage/" class="nav-link--featured">StartPage ★</a></li> + <li><a href="https://www.sillylaird.ca/map/">Site Map</a></li> + </ul> + <ul class="nav-section" aria-label="Writing"> + <li class="nav-label">Writing</li> + <li><a href="https://blog.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Blog</a></li> + <li><a href="https://www.sillylaird.ca/journal/">Journal</a></li> + <li><a href="https://www.sillylaird.ca/changelog/">Changelog</a></li> + <li><a href="https://diary.sillylaird.ca/bbs.php" target="_blank" rel="noopener noreferrer">Diary</a></li> + <li><a href="/guestbook.php" target="_blank" rel="noopener noreferrer">Guestbook</a></li> + </ul> + <ul class="nav-section" aria-label="Collections"> + <li class="nav-label">Collections</li> + <li><a href="https://www.sillylaird.ca/gaming/">Gaming</a></li> + <li><a href="https://www.sillylaird.ca/gaming/collection/">Game Collection</a></li> + <li><a href="https://www.sillylaird.ca/bookmarks/">Bookmarks</a></li> + <li><a href="https://www.sillylaird.ca/accounts/">Accounts</a></li> + <li><a href="https://www.sillylaird.ca/computers/">Computers</a></li> + </ul> + <ul class="nav-section" aria-label="Media"> + <li class="nav-label">Media</li> + <li><a href="https://radio.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Radio</a></li> + <li><a href="https://live.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Live</a></li> + </ul> + <ul class="nav-section" aria-label="Mail"> + <li class="nav-label">Mail</li> + <li><a href="https://mail.google.com/mail/u/0/#inbox">Gmail</a></li> + <li><a href="https://mail.sillylaird.ca/">Mail</a></li> + <li><a href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion:8025/src/login.php">Mail@Tor</a></li> + </ul> + <ul class="nav-section" aria-label="Networks"> + <li class="nav-label">Networks</li> + <li><a href="http://xyb4zkpbnssz2kgiklg5vfbepbnblykgfvemwadvufwzfcrjny46q6qd.onion/">Tor</a></li> + <li><a href="http://sillylaird.i2p/">I2P</a></li> + <li><a href="http://9p.sillylaird.ca" target="_blank" rel="noopener noreferrer">9p/9f</a></li> + <li><a href="http://xp.sillylaird.ca" target="_blank" rel="noopener noreferrer">xp</a></li> + <li><a href="gopher://sillylaird.ca" target="_blank" rel="noopener noreferrer">Gopher</a></li> + <li><a href="gemini://sillylaird.ca" target="_blank" rel="noopener noreferrer">Gemini</a></li> + </ul> + <ul class="nav-section" aria-label="External"> + <li class="nav-label">External</li> + <li><a href="https://www.heyaoi.net" target="_blank" rel="noopener noreferrer">Heyaoi</a></li> + <li><a href="https://micro.heyaoi.online/" target="_blank" rel="noopener noreferrer">Heyaoi Online</a></li> + <li><a href="https://git.sillylaird.ca/" target="_blank" rel="noopener noreferrer">Git</a></li> + <li><a href="https://cvs.sillylaird.ca/" target="_blank" rel="noopener noreferrer">CVS</a></li> + </ul> + </nav> + </div> + </div> + </div> + <?php if (!$_is_startpage) { + include __DIR__ . '/header_search.php'; + } ?> +</header> diff --git a/partials/header_search.php b/partials/header_search.php new file mode 100644 index 0000000..85b57e4 --- /dev/null +++ b/partials/header_search.php @@ -0,0 +1,34 @@ +<?php +/** Header search bar — sits inside site-header, aligned with header-inner width. */ +?> +<div class="header-search" id="header-search" role="search" aria-label="Search the web or this site"> + <div class="header-search-inner"> + <form id="gh-search-form" + class="gh-search-form" + method="get" + action="https://www.google.ca/search" + target="_blank" + rel="noopener noreferrer"> + <div class="unified-search"> + <div class="search-engine-picker"> + <button type="button" + id="gh-search-engine-toggle" + class="search-engine-toggle" + aria-haspopup="listbox" + aria-expanded="false" + aria-label="Choose search engine">G <span class="search-engine-caret" aria-hidden="true">▾</span></button> + <ul id="gh-search-engine-menu" class="search-engine-menu" role="listbox" hidden></ul> + </div> + <label class="visually-hidden" for="gh-search-input">Search query</label> + <input type="search" + id="gh-search-input" + name="q" + class="unified-search-input" + placeholder="Search Google…" + autocomplete="off" + enterkeyhint="search" /> + </div> + </form> + <ul id="site-search-hits" class="site-search-hits" role="listbox" aria-label="Matching pages on this site" hidden></ul> + </div> +</div>
\ No newline at end of file diff --git a/partials/i18n.php b/partials/i18n.php new file mode 100644 index 0000000..6f76419 --- /dev/null +++ b/partials/i18n.php @@ -0,0 +1,40 @@ +<?php +// Minimal i18n helper. Detects language from the current filename suffix +// (_jp.php / _zh.php) so it matches the existing URL scheme, and loads a +// flat key -> string dict from locales/{code}.php. +// +// Usage in a page: +// require_once __DIR__ . '/../partials/i18n.php'; // adjust relative path +// echo t('home.title'); +// +// Missing keys fall back to the English dict, then to the key itself. + +if (!function_exists('t')) { + function _i18n_lang(): string { + // Prefer SCRIPT_NAME: under nginx + php-fpm, PHP_SELF is often empty. + $f = basename((string)($_SERVER['SCRIPT_NAME'] ?? $_SERVER['PHP_SELF'] ?? 'index.php')); + if ($f === '') $f = 'index.php'; + if (str_ends_with($f, '_zh.php')) return 'zh'; + if (str_ends_with($f, '_jp.php')) return 'ja'; + return 'en'; + } + + function _i18n_load(string $code): array { + static $cache = []; + if (isset($cache[$code])) return $cache[$code]; + $path = __DIR__ . '/../locales/' . $code . '.php'; + $cache[$code] = is_file($path) ? (array)include $path : []; + return $cache[$code]; + } + + function t(string $key, ?string $lang = null): string { + $lang = $lang ?? _i18n_lang(); + $dict = _i18n_load($lang); + if (array_key_exists($key, $dict)) return $dict[$key]; + if ($lang !== 'en') { + $en = _i18n_load('en'); + if (array_key_exists($key, $en)) return $en[$key]; + } + return $key; + } +} diff --git a/partials/partials_vibe.php b/partials/partials_vibe.php new file mode 100644 index 0000000..aa77598 --- /dev/null +++ b/partials/partials_vibe.php @@ -0,0 +1,212 @@ +<?php +// partials/vibe.php — renders "My Current Vibe" section +// Reads from vibe.php config, detects URL type and embeds accordingly. + +$vibe = file_exists(__DIR__ . '/../vibe.php') + ? require __DIR__ . '/../vibe.php' + : ['description' => '', 'url' => '', 'updated' => '']; + +$vibe_section_title_raw = $vibe_section_title ?? 'My Current Vibe'; +$vibe_desc_raw = trim((string)($vibe['description'] ?? '')); +$vibe_url = $vibe['url'] ?? ''; +$vibe_updated = htmlspecialchars($vibe['updated'] ?? '', ENT_QUOTES, 'UTF-8'); + +// Hide description if it is empty or duplicates the section heading +$show_desc = $vibe_desc_raw !== '' && strcasecmp($vibe_desc_raw, $vibe_section_title_raw) !== 0; +$vibe_desc = htmlspecialchars($vibe_desc_raw, ENT_QUOTES, 'UTF-8'); + +function vibe_detect_type(string $url): string { + if (empty($url)) return 'none'; + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ''); + $ext = pathinfo($path, PATHINFO_EXTENSION); + + $images = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif']; + $videos = ['mp4', 'webm', 'ogv', 'mov']; + $audio = ['mp3', 'flac', 'wav', 'ogg', 'aac', 'opus', 'm4a']; + + if (in_array($ext, $images)) return 'image'; + if (in_array($ext, $videos)) return 'video'; + if (in_array($ext, $audio)) return 'audio'; + + if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)([A-Za-z0-9_-]{11})/', $url, $m)) + return 'youtube:' . $m[1]; + + if (preg_match('/vimeo\.com\/(?:video\/)?(\d+)/', $url, $m)) + return 'vimeo:' . $m[1]; + + // Dailymotion: dailymotion.com/video/{id} or dai.ly/{id} (id may have a _slug) + if (preg_match('#(?:dailymotion\.com/video|dai\.ly)/([A-Za-z0-9]+)#i', $url, $m)) + return 'dailymotion:' . $m[1]; + + // Bilibili: bilibili.com/video/{BVxxxx} (BV id) or /video/av{n} + if (preg_match('#bilibili\.com/video/(BV[A-Za-z0-9]+)#i', $url, $m)) + return 'bilibili:bvid:' . $m[1]; + if (preg_match('#bilibili\.com/video/av(\d+)#i', $url, $m)) + return 'bilibili:aid:' . $m[1]; + + // Niconico: nicovideo.jp/watch/{sm123} or nico.ms/{sm123} + if (preg_match('#(?:nicovideo\.jp/watch|nico\.ms)/((?:sm|nm|so)?\d+)#i', $url, $m)) + return 'niconico:' . $m[1]; + + return 'link'; +} + +// Extract ?t= / &t= / #t= start offset for YouTube (supports `90`, `1m30s`, `1h2m3s`) +function vibe_youtube_start(string $url): int { + $q = []; + parse_str((string)parse_url($url, PHP_URL_QUERY), $q); + $t = $q['t'] ?? $q['start'] ?? null; + if ($t === null) { + $frag = parse_url($url, PHP_URL_FRAGMENT) ?? ''; + if (preg_match('/(?:^|&)t=([^&]+)/', $frag, $m)) $t = $m[1]; + } + if ($t === null) return 0; + if (ctype_digit((string)$t)) return (int)$t; + if (preg_match('/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/', $t, $m)) { + return ((int)($m[1] ?? 0)) * 3600 + ((int)($m[2] ?? 0)) * 60 + ((int)($m[3] ?? 0)); + } + return 0; +} + +// Map a detected iframe-provider type to an embed URL + friendly label. +// Returns [] for non-iframe types (image/audio/video/link/none). +function vibe_provider_embed(string $type, string $url): array { + if (str_starts_with($type, 'youtube:')) { + $id = substr($type, 8); + $start = vibe_youtube_start($url); + $embed = 'https://www.youtube-nocookie.com/embed/' . rawurlencode($id) + . '?rel=0&modestbranding=1' . ($start > 0 ? '&start=' . $start : ''); + return ['embed' => $embed, 'label' => 'YouTube']; + } + if (str_starts_with($type, 'vimeo:')) { + $id = substr($type, 6); + return ['embed' => 'https://player.vimeo.com/video/' . rawurlencode($id) . '?dnt=1', 'label' => 'Vimeo']; + } + if (str_starts_with($type, 'dailymotion:')) { + $id = substr($type, 12); + return ['embed' => 'https://geo.dailymotion.com/player.html?video=' . rawurlencode($id), 'label' => 'Dailymotion']; + } + if (str_starts_with($type, 'bilibili:bvid:')) { + $id = substr($type, 14); + return ['embed' => 'https://player.bilibili.com/player.html?bvid=' . rawurlencode($id) . '&page=1&high_quality=1&danmaku=0', 'label' => 'Bilibili']; + } + if (str_starts_with($type, 'bilibili:aid:')) { + $id = substr($type, 13); + return ['embed' => 'https://player.bilibili.com/player.html?aid=' . rawurlencode($id) . '&page=1&high_quality=1&danmaku=0', 'label' => 'Bilibili']; + } + if (str_starts_with($type, 'niconico:')) { + $id = substr($type, 9); + return ['embed' => 'https://embed.nicovideo.jp/watch/' . rawurlencode($id), 'label' => 'Niconico']; + } + return []; +} + +$type = vibe_detect_type($vibe_url); +$safe_url = htmlspecialchars($vibe_url, ENT_QUOTES, 'UTF-8'); +?> + +<style> + .vibe-embed { + position: relative; + padding-top: 56.25%; + overflow: hidden; + border-radius: 6px; + background: #000; + margin: 0.5rem 0; + } + .vibe-embed > iframe, + .vibe-embed > video, + .vibe-embed > img { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + border: 0; + object-fit: contain; + } + .vibe-media-audio { width: 100%; margin: 0.5rem 0; } + .vibe-media-img { max-width: 100%; height: auto; border-radius: 6px; } + .vibe-caption { + margin: 0.25rem 0 0; + font-size: 0.9em; + opacity: 0.8; + } + .vibe-meta { + font-size: 0.85em; + opacity: 0.7; + margin: 0.5rem 0 0; + } +</style> + +<section id="vibe" aria-labelledby="vibe-title" class="vibe-section"> + <h2 id="vibe-title" class="section-accent-title"><?= htmlspecialchars($vibe_section_title_raw, ENT_QUOTES, 'UTF-8') ?></h2> + + <?php if ($show_desc): ?> + <p><?= $vibe_desc ?></p> + <?php endif; ?> + + <?php if ($vibe_url): ?> + <?php if ($type === 'image'): ?> + <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"> + <img class="vibe-media-img" src="<?= $safe_url ?>" alt="Current vibe" loading="lazy" decoding="async"> + </a> + + <?php elseif ($type === 'video'): ?> + <div class="vibe-embed"> + <video controls preload="none"> + <source src="<?= $safe_url ?>"> + </video> + </div> + <p class="vibe-caption"> + <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Open video ↗</a> + </p> + + <?php elseif ($type === 'audio'): ?> + <audio class="vibe-media-audio" controls preload="none"> + <source src="<?= $safe_url ?>"> + Your browser does not support audio. <a href="<?= $safe_url ?>">Download it</a>. + </audio> + <p class="vibe-caption"> + <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Open audio ↗</a> + </p> + + <?php elseif ($prov = vibe_provider_embed($type, $vibe_url)): ?> + <?php + $embed = $prov['embed']; + $label = $prov['label']; + $safe_embed = htmlspecialchars($embed, ENT_QUOTES); + $safe_label = htmlspecialchars($label, ENT_QUOTES); + ?> + <div class="vibe-embed"> + <noscript> + <iframe + src="<?= $safe_embed ?>" + title="Current vibe — <?= $safe_label ?> video" + loading="lazy" + referrerpolicy="strict-origin-when-cross-origin" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" + allowfullscreen></iframe> + </noscript> + <button type="button" class="vibe-facade js-only" + data-embed="<?= $safe_embed ?>" + aria-label="Load and play the <?= $safe_label ?> video"> + <span class="vibe-facade-play" aria-hidden="true">▶</span> + <span class="vibe-facade-text">Load <?= $safe_label ?> video<br><small>Nothing loads from <?= $safe_label ?> until you click</small></span> + </button> + </div> + <p class="vibe-caption"> + <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer">Watch on <?= $safe_label ?> ↗</a> + </p> + + <?php else: ?> + <p> + <a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a> + </p> + <?php endif; ?> + <?php endif; ?> + + <?php if ($vibe_updated): ?> + <p class="vibe-meta">Updated <?= $vibe_updated ?></p> + <?php endif; ?> + + <?php if (!empty($vibe_section_extra)) echo $vibe_section_extra; ?> +</section> diff --git a/partials/proxy_helpers.php b/partials/proxy_helpers.php new file mode 100644 index 0000000..59ac3cb --- /dev/null +++ b/partials/proxy_helpers.php @@ -0,0 +1,80 @@ +<?php +// partials/proxy_helpers.php — small shared helpers for the *-proxy.php endpoints. +// +// - lastfm_api_key(): read LASTFM_API_KEY from env (with legacy fallback). +// - proxy_host_is_safe(): reject hosts that resolve to private/reserved IPs (SSRF guard). +// - proxy_rate_limit(): simple per-IP sliding-window limit, file-backed. + +if (!function_exists('lastfm_api_key')) { + function lastfm_api_key(): string { + $env = getenv('LASTFM_API_KEY'); + if (is_string($env) && $env !== '') return $env; + throw new RuntimeException('LASTFM_API_KEY is not set in the php-fpm environment'); + } +} + +if (!function_exists('proxy_host_is_safe')) { + function proxy_host_is_safe(string $host): bool { + if ($host === '') return false; + // Reject anything that looks like a literal IPv6 with brackets stripped earlier. + $records = @dns_get_record($host, DNS_A | DNS_AAAA); + $ips = []; + if (is_array($records)) { + foreach ($records as $r) { + if (!empty($r['ip'])) $ips[] = $r['ip']; + if (!empty($r['ipv6'])) $ips[] = $r['ipv6']; + } + } + // Also check if the host is itself an IP literal. + if (filter_var($host, FILTER_VALIDATE_IP)) { + $ips[] = $host; + } + if (!$ips) return false; // unresolvable -> deny + foreach ($ips as $ip) { + $ok = filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + if ($ok === false) return false; + } + return true; + } +} + +if (!function_exists('proxy_rate_limit')) { + /** + * Returns true if the request is allowed. Uses a tiny file per + * (endpoint, client IP) holding recent unix timestamps. + */ + function proxy_rate_limit(string $endpoint, int $max = 30, int $windowSec = 60): bool { + $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + $key = preg_replace('/[^A-Za-z0-9._-]/', '_', $endpoint . '_' . $ip); + $dir = sys_get_temp_dir() . '/sillylaird_ratelimit'; + if (!is_dir($dir)) @mkdir($dir, 0700, true); + $file = $dir . '/' . $key; + + $now = time(); + $cutoff = $now - $windowSec; + + $fp = @fopen($file, 'c+'); + if (!$fp) return true; // fail-open: don't block legit traffic on disk error + try { + flock($fp, LOCK_EX); + $data = stream_get_contents($fp); + $stamps = $data === '' ? [] : array_map('intval', explode("\n", trim($data))); + $stamps = array_values(array_filter($stamps, fn($t) => $t >= $cutoff)); + if (count($stamps) >= $max) { + return false; + } + $stamps[] = $now; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, implode("\n", $stamps)); + return true; + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } + } +} diff --git a/partials/recent_static.php b/partials/recent_static.php new file mode 100644 index 0000000..484bd18 --- /dev/null +++ b/partials/recent_static.php @@ -0,0 +1,195 @@ +<?php +// partials/recent_static.php +// Cheap server-side prerender of recent blog/changelog items so users do +// not stare at "Loading…" while JS is still booting (and so the page is +// useful even when the fetch fails). + +const RECENT_CACHE_TTL = 300; // 5 min + +function _recent_cache_get(string $key, int $ttl, int $invalidate_before = 0) { + $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json'; + if (!is_file($f)) return null; + $mt = filemtime($f); + // Stale if older than TTL, or older than the upstream source mtime + if ((time() - $mt) >= $ttl) return null; + if ($invalidate_before > 0 && $mt < $invalidate_before) return null; + $raw = @file_get_contents($f); + if ($raw === false) return null; + $data = json_decode($raw, true); + return is_array($data) ? $data : null; +} + +function _recent_changelog_source_mtime(): int { + $latest = 0; + foreach (glob(__DIR__ . '/../changelog/*-changelog.txt') ?: [] as $f) { + $m = @filemtime($f); + if ($m && $m > $latest) $latest = $m; + } + return $latest; +} + +function _recent_cache_put(string $key, array $data): void { + $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json'; + @file_put_contents($f, json_encode($data), LOCK_EX); +} + +// Last-resort fallback: return whatever is cached regardless of age, so an +// upstream hiccup never blanks the list (stale-on-error). +function _recent_cache_get_stale(string $key): ?array { + $f = sys_get_temp_dir() . '/sillylaird_recent_' . $key . '.json'; + if (!is_file($f)) return null; + $raw = @file_get_contents($f); + if ($raw === false) return null; + $data = json_decode($raw, true); + return is_array($data) ? $data : null; +} + +const BLOG_SQLITE = '/mnt/slab/blog.sillylaird.ca/blog.sqlite'; + +function _recent_fetch(string $url, float $timeout = 1.5): ?string { + $ctx = stream_context_create(['http' => [ + 'timeout' => $timeout, + 'ignore_errors' => true, + 'header' => "User-Agent: sillylaird-static-render/1.0\r\n", + ]]); + $body = @file_get_contents($url, false, $ctx); + return $body === false ? null : $body; +} + +function recent_blog_posts(int $limit = 2): array { + // Invalidate cache when the blog DB changes so new posts show within the + // window without waiting out the full TTL. + $src_mtime = @filemtime(BLOG_SQLITE) ?: 0; + $cached = _recent_cache_get('blog', RECENT_CACHE_TTL, $src_mtime); + if ($cached !== null) return array_slice($cached, 0, $limit); + + // Blog runs on the same box; read its SQLite directly instead of an HTTP + // round-trip to blog.sillylaird.ca (the old fetch blocked ~1.5s on every + // cache miss and stalled when the blog vhost was slow/down). + $posts = null; + if (is_file(BLOG_SQLITE)) { + try { + $db = new PDO('sqlite:' . BLOG_SQLITE, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 2, + ]); + $stmt = $db->query('SELECT title, path, year, month, day FROM posts ORDER BY id DESC'); + $posts = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + $posts = null; + } + } + + if (!is_array($posts)) { + // DB unavailable — serve the last good list rather than blanking. + $stale = _recent_cache_get_stale('blog'); + return $stale !== null ? array_slice($stale, 0, $limit) : []; + } + + _recent_cache_put('blog', $posts); + return array_slice($posts, 0, $limit); +} + +function recent_changelog_entries(int $limit = 2): array { + $cached = _recent_cache_get('changelog', RECENT_CACHE_TTL, _recent_changelog_source_mtime()); + if ($cached !== null) return array_slice($cached, 0, $limit); + + $html = _recent_fetch('https://www.sillylaird.ca/changelog/latest.php'); + if ($html === null) { + $stale = _recent_cache_get_stale('changelog'); + return $stale !== null ? array_slice($stale, 0, $limit) : []; + } + + $entries = []; + $doc = new DOMDocument(); + libxml_use_internal_errors(true); + if (!$doc->loadHTML('<?xml encoding="UTF-8">' . $html)) { + libxml_clear_errors(); + $stale = _recent_cache_get_stale('changelog'); + return $stale !== null ? array_slice($stale, 0, $limit) : []; + } + libxml_clear_errors(); + $xp = new DOMXPath($doc); + foreach ($xp->query('//div[contains(@class,"changelog-entry")]') as $node) { + /** @var DOMElement $node */ + $title = ''; + foreach (['b', 'strong'] as $tag) { + $el = $node->getElementsByTagName($tag)->item(0); + if ($el) { $title = trim($el->textContent); break; } + } + $timeEl = $node->getElementsByTagName('time')->item(0); + $iso = $timeEl ? trim($timeEl->getAttribute('datetime')) : ''; + $when = $timeEl ? trim($timeEl->textContent) : ''; + + $body = ''; + foreach ($xp->query('.//span[contains(@class,"text")]', $node) as $b) { + $body = trim($b->textContent); + break; + } + $entries[] = compact('title', 'iso', 'when', 'body'); + } + + _recent_cache_put('changelog', $entries); + return array_slice($entries, 0, $limit); +} + +function render_recent_blog(int $limit = 2): void { + $posts = recent_blog_posts($limit); + if (!$posts) { + echo '<div class="muted">No posts to show right now.</div>'; + return; + } + $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + echo '<div><strong>Posts</strong> (<a href="https://blog.sillylaird.ca/">all</a>)</div>'; + echo '<ol class="recent-list">'; + foreach ($posts as $p) { + $path = ltrim((string)($p['path'] ?? ''), '/'); + $iso = sprintf('%04d-%02d-%02d', (int)($p['year'] ?? 0), (int)($p['month'] ?? 0), (int)($p['day'] ?? 0)); + $iso = $iso === '0000-00-00' ? '' : $iso; + echo '<li><a href="https://blog.sillylaird.ca/' . $h($path) . '">' . $h($p['title'] ?? 'Untitled') . '</a>'; + if ($iso) echo '<div class="muted"><time datetime="' . $h($iso) . '">' . $h($iso) . '</time></div>'; + echo '</li>'; + } + echo '</ol>'; +} + +/** Latest changelog row for sidebar widgets (reads local TXT, no HTTP). */ +function latest_changelog_entry(): ?array { + static $entry = null; + static $loaded = false; + if ($loaded) return $entry; + $loaded = true; + + $parser = __DIR__ . '/../changelog/api/parser.php'; + if (!is_file($parser)) return null; + require_once $parser; + + $rows = get_changelog_entries(1); + $entry = $rows[0] ?? null; + return $entry; +} + +function render_recent_changelog(int $limit = 2): void { + $entries = recent_changelog_entries($limit); + if (!$entries) { + echo '<div class="muted">No entries to show right now.</div>'; + return; + } + $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + echo '<div><strong>Entries</strong> (<a href="https://www.sillylaird.ca/changelog/">all</a>)</div>'; + echo '<ol class="recent-list">'; + foreach ($entries as $e) { + echo '<li>'; + if ($e['title']) echo '<strong>' . $h($e['title']) . '</strong>'; + if ($e['iso'] || $e['when']) { + echo '<div class="muted"><time'; + if ($e['iso']) echo ' datetime="' . $h($e['iso']) . '"'; + echo '>' . $h($e['when'] ?: $e['iso']) . '</time></div>'; + } + if ($e['body']) echo '<div>' . $h($e['body']) . '</div>'; + $yr = $e['iso'] ? substr((string)$e['iso'], 0, 4) : ''; + if ($yr) echo '<a class="changelog-more" href="https://www.sillylaird.ca/changelog/#year-' . $h($yr) . '">more (' . $h($yr) . ') →</a>'; + echo '</li>'; + } + echo '</ol>'; +} diff --git a/partials/session.php b/partials/session.php new file mode 100644 index 0000000..d4ee141 --- /dev/null +++ b/partials/session.php @@ -0,0 +1,15 @@ +<?php +// Shared session bootstrap. Must be included before any output. +// Security headers (CSP, HSTS, nosniff, Referrer-Policy, Permissions-Policy) +// are owned by nginx — do not re-emit them here (browsers can conflict on +// duplicate policies). +if (session_status() !== PHP_SESSION_ACTIVE) { + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} diff --git a/partials/site_nav_panel.php b/partials/site_nav_panel.php new file mode 100644 index 0000000..a23d104 --- /dev/null +++ b/partials/site_nav_panel.php @@ -0,0 +1,44 @@ +<?php +// partials/site_nav_panel.php — curated "Explore SillyLaird" link panel. +// xahlee.info <nav class="lpanelxl"> style: a titled box with a tight vertical +// link list, some entries carrying an enlarged emoji (.emojixl). +// Shown on every non-homepage page (included from partials/footer.php). +// +// To curate: edit $panel_links below. ['href','label','emoji','external']. +$panel_links = [ + ['/now/', 'Now', '📌'], + ['/motd/', 'MOTD', '📜'], + ['/journal/', 'Journal', '📓'], + ['https://blog.sillylaird.ca/', 'Blog', '✍', true], + ['/gaming/', 'Gaming archive', '🎮'], + ['/gaming/collection/', 'Game Collection', '📼'], + ['/gaming/multibox/', 'Multiboxing', '⚔'], + ['/musictaste/', 'Music taste', '🎵'], + ['/computers/', 'Computers', '🖥'], + ['/bookmarks/', 'Bookmarks', '🔖'], + ['/links.php', 'Links', '🔗'], + ['/hatCollectionWebsite.php', 'Hat collection', '🧢'], + ['/startpage/', 'StartPage', '🏠'], + ['/changelog/', 'Changelog', '🛠'], + ['/guestbook.php', 'Guestbook', '📖'], + ['/vblog/', 'vBlog', '🎬'], + ['/whatsleft/', "What's left", '🧹'], + ['/Laird_Lonergan_profile.php', 'Profile', '👤'], + ['/map/', 'Site map', '🗺'], +]; +?> +<div class="wrap"> + <nav class="lpanelxl" aria-label="Explore SillyLaird"> + <h3>Explore SillyLaird</h3> + <ul> +<?php foreach ($panel_links as $l): + $href = htmlspecialchars($l[0], ENT_QUOTES, 'UTF-8'); + $label = htmlspecialchars($l[1], ENT_QUOTES, 'UTF-8'); + $emoji = htmlspecialchars($l[2] ?? '', ENT_QUOTES, 'UTF-8'); + $ext = !empty($l[3]); +?> + <li><a href="<?= $href ?>"<?= $ext ? ' target="_blank" rel="noopener noreferrer"' : '' ?>><?= $label ?><?php if ($emoji): ?> <span class="emojixl"><?= $emoji ?></span><?php endif; ?></a></li> +<?php endforeach; ?> + </ul> + </nav> +</div> diff --git a/partials/site_status.php b/partials/site_status.php new file mode 100644 index 0000000..c758186 --- /dev/null +++ b/partials/site_status.php @@ -0,0 +1,70 @@ +<?php + +function site_latest_update_timestamp(): int +{ + static $latest = null; + if ($latest !== null) { + return $latest; + } + + $root = dirname(__DIR__); + $latest = 0; + $extensions = ['php' => true, 'html' => true, 'css' => true, 'js' => true, 'txt' => true]; + $skipDirs = ['.git' => true, '.agents' => true, 'node_modules' => true, 'vendor' => true]; + + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + static function (SplFileInfo $current) use ($skipDirs): bool { + if ($current->isDir()) { + return !isset($skipDirs[$current->getFilename()]); + } + return true; + } + ) + ); + + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + $extension = strtolower($file->getExtension()); + if (!isset($extensions[$extension])) { + continue; + } + $mtime = @filemtime($file->getPathname()); + if ($mtime && $mtime > $latest) { + $latest = $mtime; + } + } + + return $latest; +} + +function site_update_status(): array +{ + $timezone = new DateTimeZone('America/New_York'); + $latest = site_latest_update_timestamp(); + $date = $latest + ? (new DateTimeImmutable('@' . $latest))->setTimezone($timezone)->format('Y-m-d') + : (new DateTimeImmutable('now', $timezone))->format('Y-m-d'); + $today = (new DateTimeImmutable('now', $timezone))->format('Y-m-d'); + + return [ + 'date' => $date, + 'updated_today' => $date === $today, + ]; +} + +function render_site_update_status(string $class = 'site-status-row'): void +{ + $status = site_update_status(); + $date = htmlspecialchars($status['date'], ENT_QUOTES, 'UTF-8'); + $label = $status['updated_today'] ? 'Updated today: check the changelog' : 'View changelog'; + ?> + <div class="<?= htmlspecialchars($class, ENT_QUOTES, 'UTF-8') ?>" aria-label="Site status"> + <span>Last updated: <time datetime="<?= $date ?>"><?= $date ?></time></span> + <a href="https://www.sillylaird.ca/changelog/"><?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?></a> + </div> + <?php +} diff --git a/partials/traffic.php b/partials/traffic.php new file mode 100644 index 0000000..085b4a7 --- /dev/null +++ b/partials/traffic.php @@ -0,0 +1,626 @@ +<?php +/** + * Traffic log helpers — privacy-friendly hit storage for sillylaird.ca. + * + * DB path: /var/lib/sillylaird/traffic.sqlite (outside the document root). + * Never write client IPs into this database. + */ + +declare(strict_types=1); + +const TRAFFIC_DB = '/var/lib/sillylaird/traffic.sqlite'; +const TRAFFIC_HIT_RETENTION_DAYS = 90; +const TRAFFIC_MAX_RECENT = 5000; + +function traffic_host_allowed(string $host): bool { + $host = strtolower(trim($host)); + if ($host === '' || str_contains($host, '/') || str_contains($host, ' ')) { + return false; + } + // Strip port if present + if (str_contains($host, ':')) { + $host = explode(':', $host, 2)[0]; + } + return $host === 'sillylaird.ca' + || $host === 'www.sillylaird.ca' + || str_ends_with($host, '.sillylaird.ca'); +} + +function traffic_normalize_path(string $path): string { + $path = trim($path); + if ($path === '') return '/'; + // Drop query/fragment if a full URL or path?query was sent + $path = preg_replace('/[?#].*$/', '', $path) ?? $path; + if ($path === '' || $path[0] !== '/') { + $path = '/' . ltrim($path, '/'); + } + // Collapse // + $path = preg_replace('#/+#', '/', $path) ?? $path; + $path = rtrim($path, '/') ?: '/'; + // /foo/index.php and lang variants → /foo + $path = preg_replace('#/index(?:_[a-z]{2})?\.php$#i', '', $path) ?? $path; + $path = $path === '' ? '/' : $path; + // /links_jp.php → /links.php + $path = preg_replace('#_(jp|zh)\.php$#i', '.php', $path) ?? $path; + // Block path traversal leftovers + if (str_contains($path, '..')) return '/'; + return $path; +} + +function traffic_page_key(string $host, string $path): string { + $host = strtolower(trim($host)); + if (str_contains($host, ':')) { + $host = explode(':', $host, 2)[0]; + } + return $host . traffic_normalize_path($path); +} + +/** + * True when the User-Agent looks like a crawler, scraper, previewer, or monitor. + * Checked before browser matching so bots that pretend to be Chrome/etc. are not + * counted as normal visitors. + */ +function traffic_is_bot(string $ua): bool { + $ua = trim($ua); + if ($ua === '') { + // Empty UA is almost never a real browser hit from our JS beacon. + return true; + } + return (bool)preg_match( + '/(?:' + . 'bot|crawler|crawl|spider|slurp|fetcher|scraper|scrapy' + . '|bingpreview|facebookexternalhit|facebot|ia_archiver|twitterbot' + . '|linkedinbot|discordbot|telegrambot|slackbot|applebot' + . '|googlebot|adsbot|mediapartners-google|apis-google|feedfetcher' + . '|yandex|baiduspider|duckduckbot|semrush|ahrefs|mj12bot|dotbot' + . '|petalbot|bytespider|gptbot|chatgpt|claudebot|anthropic|ccbot' + . '|amazonbot|meta-externalagent|omgilibot|imagesiftbot' + . '|wget|curl|python-requests|python-urllib|httpx|libwww|httpclient' + . '|go-http-client|java\/|okhttp|php\/|node-fetch|axios\/' + . '|headlesschrome|phantomjs|selenium|puppeteer|playwright' + . '|uptimerobot|pingdom|statuscake|site24x7|check_http' + . '|pagespeed|gtmetrix' + . ')/i', + $ua + ); +} + +/** True if a stored visitor label is a bot (new + legacy rows). */ +function traffic_visitor_is_bot(string $visitor, ?int $isBotFlag = null): bool { + if ($isBotFlag !== null) { + return $isBotFlag === 1; + } + $v = strtolower(trim($visitor)); + if ($v === '' || $v === 'unknown visitor') { + return false; + } + return $v === 'bot' || str_starts_with($v, 'bot ·') || str_starts_with($v, 'bot '); +} + +/** + * Coarse browser · OS label from User-Agent. Intentionally low-resolution — + * not a fingerprint, not an IP, and not the raw UA string. + * Bots are labeled separately (never as a normal browser · OS pair). + */ +function traffic_visitor_label(string $ua): string { + $ua = trim($ua); + if ($ua === '') return 'Bot · empty-ua'; + + // Bots first — do not classify them as Chrome/Firefox/etc. + if (traffic_is_bot($ua)) { + if (preg_match('/googlebot|adsbot-google|mediapartners-google/i', $ua)) return 'Bot · Google'; + if (preg_match('/bingbot|bingpreview|msnbot/i', $ua)) return 'Bot · Bing'; + if (preg_match('/yandex/i', $ua)) return 'Bot · Yandex'; + if (preg_match('/baiduspider/i', $ua)) return 'Bot · Baidu'; + if (preg_match('/duckduckbot/i', $ua)) return 'Bot · DuckDuckGo'; + if (preg_match('/applebot/i', $ua)) return 'Bot · Apple'; + if (preg_match('/facebookexternalhit|facebot|meta-externalagent/i', $ua)) return 'Bot · Meta'; + if (preg_match('/twitterbot/i', $ua)) return 'Bot · Twitter'; + if (preg_match('/discordbot/i', $ua)) return 'Bot · Discord'; + if (preg_match('/slackbot/i', $ua)) return 'Bot · Slack'; + if (preg_match('/telegrambot/i', $ua)) return 'Bot · Telegram'; + if (preg_match('/linkedinbot/i', $ua)) return 'Bot · LinkedIn'; + if (preg_match('/gptbot|chatgpt|claudebot|anthropic|ccbot/i', $ua)) return 'Bot · AI'; + if (preg_match('/semrush|ahrefs|mj12bot|dotbot|petalbot|bytespider/i', $ua)) return 'Bot · SEO'; + if (preg_match('/wget|curl|python-|httpx|libwww|go-http-client|java\/|okhttp|php\//i', $ua)) return 'Bot · script'; + if (preg_match('/uptimerobot|pingdom|statuscake|site24x7|check_http|monitoring/i', $ua)) return 'Bot · monitor'; + return 'Bot'; + } + + $browser = 'Browser'; + if (preg_match('/Edg(?:e|A|iOS)?\//i', $ua)) $browser = 'Edge'; + elseif (preg_match('/OPR\/|Opera/i', $ua)) $browser = 'Opera'; + elseif (preg_match('/SamsungBrowser\//i', $ua)) $browser = 'Samsung'; + elseif (preg_match('/Firefox\//i', $ua)) $browser = 'Firefox'; + elseif (preg_match('/CriOS\//i', $ua)) $browser = 'Chrome'; + elseif (preg_match('/Chrome\//i', $ua) && !preg_match('/Chromium/i', $ua)) $browser = 'Chrome'; + elseif (preg_match('/Chromium\//i', $ua)) $browser = 'Chromium'; + elseif (preg_match('/Safari\//i', $ua) && preg_match('/Version\//i', $ua)) $browser = 'Safari'; + elseif (preg_match('/MSIE |Trident\//i', $ua)) $browser = 'IE'; + + $os = 'Unknown OS'; + if (preg_match('/Android/i', $ua)) $os = 'Android'; + elseif (preg_match('/iPhone|iPad|iPod/i', $ua)) $os = 'iOS'; + elseif (preg_match('/Windows NT/i', $ua)) $os = 'Windows'; + elseif (preg_match('/Mac OS X|Macintosh/i', $ua)) $os = 'macOS'; + elseif (preg_match('/CrOS/i', $ua)) $os = 'ChromeOS'; + elseif (preg_match('/Linux/i', $ua)) $os = 'Linux'; + elseif (preg_match('/FreeBSD/i', $ua)) $os = 'BSD'; + + return $browser . ' · ' . $os; +} + +function traffic_lang(string $acceptLang): string { + $acceptLang = trim($acceptLang); + if ($acceptLang === '') return ''; + // Primary tag only: "en-CA,en;q=0.9" → "en" + if (preg_match('/^([a-zA-Z]{2,3})(?:[-_][a-zA-Z0-9]+)?/', $acceptLang, $m)) { + return strtolower($m[1]); + } + return ''; +} + +function traffic_ref_host(string $ref): string { + $ref = trim($ref); + if ($ref === '') return ''; + $host = parse_url($ref, PHP_URL_HOST); + if (!is_string($host) || $host === '') return ''; + $host = strtolower($host); + // Don't store long junk + return strlen($host) > 200 ? '' : $host; +} + +function traffic_db(): PDO { + static $pdo = null; + if ($pdo instanceof PDO) return $pdo; + + $dir = dirname(TRAFFIC_DB); + if (!is_dir($dir)) { + @mkdir($dir, 02770, true); + } + + $pdo = new PDO('sqlite:' . TRAFFIC_DB, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->exec('PRAGMA journal_mode=WAL'); + $pdo->exec('PRAGMA synchronous=NORMAL'); + $pdo->exec('PRAGMA busy_timeout=3000'); + + $pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS hits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + host TEXT NOT NULL, + path TEXT NOT NULL, + page_key TEXT NOT NULL, + visitor TEXT NOT NULL DEFAULT '', + lang TEXT NOT NULL DEFAULT '', + ref_host TEXT NOT NULL DEFAULT '', + local_count INTEGER NOT NULL DEFAULT 0, + is_new INTEGER NOT NULL DEFAULT 0, + is_bot INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_hits_ts ON hits(ts); +CREATE INDEX IF NOT EXISTS idx_hits_page ON hits(page_key); +CREATE INDEX IF NOT EXISTS idx_hits_host ON hits(host); + +CREATE TABLE IF NOT EXISTS pages ( + page_key TEXT PRIMARY KEY, + host TEXT NOT NULL, + path TEXT NOT NULL, + hits INTEGER NOT NULL DEFAULT 0, + unique_sessions INTEGER NOT NULL DEFAULT 0, + bot_hits INTEGER NOT NULL DEFAULT 0, + last_hit INTEGER NOT NULL DEFAULT 0, + last_visitor TEXT NOT NULL DEFAULT '', + last_local_count INTEGER NOT NULL DEFAULT 0 +); +SQL); + + // Migrations for DBs created before bot separation. + $hitCols = $pdo->query('PRAGMA table_info(hits)')->fetchAll(); + $hitColNames = array_column($hitCols, 'name'); + if (!in_array('is_bot', $hitColNames, true)) { + $pdo->exec('ALTER TABLE hits ADD COLUMN is_bot INTEGER NOT NULL DEFAULT 0'); + // Backfill: legacy "Bot" labels (and "Bot · …" if any) are bots. + $pdo->exec( + "UPDATE hits SET is_bot = 1 + WHERE lower(trim(visitor)) = 'bot' + OR lower(visitor) LIKE 'bot ·%' + OR lower(visitor) LIKE 'bot %'" + ); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_hits_is_bot ON hits(is_bot)'); + } + + $pageCols = $pdo->query('PRAGMA table_info(pages)')->fetchAll(); + $pageColNames = array_column($pageCols, 'name'); + if (!in_array('bot_hits', $pageColNames, true)) { + $pdo->exec('ALTER TABLE pages ADD COLUMN bot_hits INTEGER NOT NULL DEFAULT 0'); + // Move retained bot hits out of human page totals where we still have log rows. + $pdo->exec( + "UPDATE pages SET + bot_hits = COALESCE(( + SELECT COUNT(*) FROM hits h + WHERE h.page_key = pages.page_key AND h.is_bot = 1 + ), 0), + hits = MAX(0, hits - COALESCE(( + SELECT COUNT(*) FROM hits h + WHERE h.page_key = pages.page_key AND h.is_bot = 1 + ), 0)), + unique_sessions = MAX(0, unique_sessions - COALESCE(( + SELECT SUM(CASE WHEN h.is_new = 1 THEN 1 ELSE 0 END) FROM hits h + WHERE h.page_key = pages.page_key AND h.is_bot = 1 + ), 0))" + ); + // Pages that only had bots (or were missing) still need a pages row for bot totals. + $pdo->exec( + "INSERT OR IGNORE INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count) + SELECT h.page_key, h.host, h.path, 0, 0, COUNT(*), MAX(h.ts), 'Bot', 0 + FROM hits h + WHERE h.is_bot = 1 + AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.page_key = h.page_key) + GROUP BY h.page_key, h.host, h.path" + ); + } + + return $pdo; +} + +/** + * @param array{host:string,path:string,visitor:string,lang:string,ref_host:string,local_count:int,is_new:int,is_bot?:int|bool} $row + * @return array{page_key:string,hits:int,unique_sessions:int,bot_hits:int,is_bot:int} + */ +function traffic_record_hit(array $row): array { + $host = strtolower(trim($row['host'])); + if (str_contains($host, ':')) { + $host = explode(':', $host, 2)[0]; + } + $path = traffic_normalize_path($row['path']); + $pageKey = $host . $path; + $now = time(); + $visitor = mb_substr((string)$row['visitor'], 0, 80); + $lang = mb_substr((string)$row['lang'], 0, 16); + $refHost = mb_substr((string)$row['ref_host'], 0, 200); + $local = (int)$row['local_count']; + $isNew = !empty($row['is_new']) ? 1 : 0; + // Explicit flag wins; otherwise infer from visitor label. + if (array_key_exists('is_bot', $row)) { + $isBot = !empty($row['is_bot']) ? 1 : 0; + } else { + $isBot = traffic_visitor_is_bot($visitor) ? 1 : 0; + } + // Never treat bots as human uniques. + if ($isBot) { + $isNew = 0; + } + + $db = traffic_db(); + $db->beginTransaction(); + try { + $ins = $db->prepare( + 'INSERT INTO hits (ts, host, path, page_key, visitor, lang, ref_host, local_count, is_new, is_bot) + VALUES (:ts, :host, :path, :pk, :vis, :lang, :ref, :lc, :new, :bot)' + ); + $ins->execute([ + ':ts' => $now, + ':host' => $host, + ':path' => $path, + ':pk' => $pageKey, + ':vis' => $visitor, + ':lang' => $lang, + ':ref' => $refHost, + ':lc' => $local, + ':new' => $isNew, + ':bot' => $isBot, + ]); + + if ($isBot) { + // Bots: log the hit, bump bot_hits only — never human hits / uniques. + $up = $db->prepare( + 'INSERT INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count) + VALUES (:pk, :host, :path, 0, 0, 1, :ts, :vis, :lc) + ON CONFLICT(page_key) DO UPDATE SET + bot_hits = bot_hits + 1, + last_hit = :ts, + last_visitor = :vis, + last_local_count = :lc' + ); + $up->execute([ + ':pk' => $pageKey, + ':host' => $host, + ':path' => $path, + ':ts' => $now, + ':vis' => $visitor, + ':lc' => $local, + ]); + } else { + $up = $db->prepare( + 'INSERT INTO pages (page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count) + VALUES (:pk, :host, :path, 1, :uniq, 0, :ts, :vis, :lc) + ON CONFLICT(page_key) DO UPDATE SET + hits = hits + 1, + unique_sessions = unique_sessions + :uniq, + last_hit = :ts, + last_visitor = :vis, + last_local_count = :lc' + ); + $up->execute([ + ':pk' => $pageKey, + ':host' => $host, + ':path' => $path, + ':uniq' => $isNew, + ':ts' => $now, + ':vis' => $visitor, + ':lc' => $local, + ]); + } + + // Occasional prune of old hit rows (keep page totals). + if (random_int(1, 50) === 1) { + $cutoff = $now - (TRAFFIC_HIT_RETENTION_DAYS * 86400); + $db->exec('DELETE FROM hits WHERE ts < ' . (int)$cutoff); + // Cap absolute row count + $cnt = (int)$db->query('SELECT COUNT(*) FROM hits')->fetchColumn(); + if ($cnt > TRAFFIC_MAX_RECENT) { + $drop = $cnt - TRAFFIC_MAX_RECENT; + $db->exec('DELETE FROM hits WHERE id IN (SELECT id FROM hits ORDER BY id ASC LIMIT ' . (int)$drop . ')'); + } + } + + $stats = $db->prepare('SELECT hits, unique_sessions, bot_hits FROM pages WHERE page_key = :pk'); + $stats->execute([':pk' => $pageKey]); + $s = $stats->fetch() ?: ['hits' => $isBot ? 0 : 1, 'unique_sessions' => $isBot ? 0 : $isNew, 'bot_hits' => $isBot ? 1 : 0]; + + $db->commit(); + return [ + 'page_key' => $pageKey, + 'hits' => (int)$s['hits'], + 'unique_sessions' => (int)$s['unique_sessions'], + 'bot_hits' => (int)($s['bot_hits'] ?? 0), + 'is_bot' => $isBot, + ]; + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + throw $e; + } +} + +/** SQL fragment: human (non-bot) hits — works with is_bot column after migration. */ +function traffic_human_where(string $alias = ''): string { + $p = $alias !== '' ? $alias . '.' : ''; + return "({$p}is_bot = 0)"; +} + +/** + * @return array{total_hits:int,total_pages:int,today:int,week:int,unique_sessions:int,bot_hits:int,bot_today:int,bot_week:int} + */ +function traffic_summary(): array { + $db = traffic_db(); + $now = time(); + $day = $now - 86400; + $week = $now - 7 * 86400; + $human = traffic_human_where(); + return [ + // Human-only page totals (bots never increment pages.hits). + 'total_hits' => (int)$db->query('SELECT COALESCE(SUM(hits),0) FROM pages')->fetchColumn(), + 'total_pages' => (int)$db->query('SELECT COUNT(*) FROM pages')->fetchColumn(), + 'today' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$day} AND {$human}")->fetchColumn(), + 'week' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$week} AND {$human}")->fetchColumn(), + 'unique_sessions' => (int)$db->query('SELECT COALESCE(SUM(unique_sessions),0) FROM pages')->fetchColumn(), + 'bot_hits' => (int)$db->query('SELECT COALESCE(SUM(bot_hits),0) FROM pages')->fetchColumn(), + 'bot_today' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$day} AND is_bot = 1")->fetchColumn(), + 'bot_week' => (int)$db->query("SELECT COUNT(*) FROM hits WHERE ts >= {$week} AND is_bot = 1")->fetchColumn(), + ]; +} + +/** @return list<array<string,mixed>> */ +function traffic_top_pages(int $limit = 50): array { + $limit = max(1, min(200, $limit)); + $st = traffic_db()->query( + 'SELECT page_key, host, path, hits, unique_sessions, bot_hits, last_hit, last_visitor, last_local_count + FROM pages ORDER BY hits DESC LIMIT ' . $limit + ); + return $st->fetchAll(); +} + +/** @return list<array<string,mixed>> */ +function traffic_recent(int $limit = 100): array { + $limit = max(1, min(500, $limit)); + $st = traffic_db()->query( + 'SELECT id, ts, host, path, visitor, lang, ref_host, local_count, is_new, is_bot + FROM hits ORDER BY id DESC LIMIT ' . $limit + ); + return $st->fetchAll(); +} + +function traffic_clear_recent(): int { + $db = traffic_db(); + $n = (int)$db->query('SELECT COUNT(*) FROM hits')->fetchColumn(); + $db->exec('DELETE FROM hits'); + return $n; +} + +function traffic_reset_all(): void { + $db = traffic_db(); + $db->exec('DELETE FROM hits'); + $db->exec('DELETE FROM pages'); +} + +/** + * Daily hit counts for the last $days calendar days (UTC), zero-filled. + * Humans and bots are separate series (bots never fold into hits/unique). + * @return list<array{day:string,hits:int,unique:int,bots:int}> + */ +function traffic_hits_by_day(int $days = 30): array { + $days = max(1, min(90, $days)); + $db = traffic_db(); + $now = time(); + $startDay = gmdate('Y-m-d', $now - ($days - 1) * 86400); + $startTs = strtotime($startDay . ' 00:00:00 UTC') ?: ($now - ($days - 1) * 86400); + + $st = $db->prepare( + 'SELECT strftime(\'%Y-%m-%d\', ts, \'unixepoch\') AS day, + SUM(CASE WHEN is_bot = 0 THEN 1 ELSE 0 END) AS hits, + SUM(CASE WHEN is_bot = 0 AND is_new = 1 THEN 1 ELSE 0 END) AS uniq, + SUM(CASE WHEN is_bot = 1 THEN 1 ELSE 0 END) AS bots + FROM hits + WHERE ts >= :start + GROUP BY day + ORDER BY day ASC' + ); + $st->execute([':start' => $startTs]); + $map = []; + foreach ($st->fetchAll() as $row) { + $map[$row['day']] = [ + 'hits' => (int)$row['hits'], + 'unique' => (int)$row['uniq'], + 'bots' => (int)$row['bots'], + ]; + } + + $out = []; + for ($i = 0; $i < $days; $i++) { + $day = gmdate('Y-m-d', $startTs + $i * 86400); + $out[] = [ + 'day' => $day, + 'hits' => $map[$day]['hits'] ?? 0, + 'unique' => $map[$day]['unique'] ?? 0, + 'bots' => $map[$day]['bots'] ?? 0, + ]; + } + return $out; +} + +/** + * Hourly hit counts for the last 24 hours (UTC), zero-filled. + * @return list<array{hour:string,hits:int,unique:int,bots:int}> + */ +function traffic_hits_by_hour(int $hours = 24): array { + $hours = max(1, min(48, $hours)); + $db = traffic_db(); + $now = time(); + // Align to hour start + $endHour = intdiv($now, 3600) * 3600; + $startTs = $endHour - ($hours - 1) * 3600; + + $st = $db->prepare( + 'SELECT strftime(\'%Y-%m-%d %H:00\', ts, \'unixepoch\') AS hour, + SUM(CASE WHEN is_bot = 0 THEN 1 ELSE 0 END) AS hits, + SUM(CASE WHEN is_bot = 0 AND is_new = 1 THEN 1 ELSE 0 END) AS uniq, + SUM(CASE WHEN is_bot = 1 THEN 1 ELSE 0 END) AS bots + FROM hits + WHERE ts >= :start + GROUP BY hour + ORDER BY hour ASC' + ); + $st->execute([':start' => $startTs]); + $map = []; + foreach ($st->fetchAll() as $row) { + $map[$row['hour']] = [ + 'hits' => (int)$row['hits'], + 'unique' => (int)$row['uniq'], + 'bots' => (int)$row['bots'], + ]; + } + + $out = []; + for ($i = 0; $i < $hours; $i++) { + $t = $startTs + $i * 3600; + $label = gmdate('Y-m-d H:00', $t); + $out[] = [ + 'hour' => $label, + 'hits' => $map[$label]['hits'] ?? 0, + 'unique' => $map[$label]['unique'] ?? 0, + 'bots' => $map[$label]['bots'] ?? 0, + ]; + } + return $out; +} + +/** + * Grouped counts from hits table. + * @param bool $humansOnly When true (default), exclude bot hits from the breakdown. + * @return list<array{label:string,count:int}> + */ +function traffic_breakdown(string $column, int $limit = 12, int $sinceTs = 0, bool $humansOnly = true): array { + $allowed = ['visitor' => 'visitor', 'host' => 'host', 'lang' => 'lang', 'ref_host' => 'ref_host']; + if (!isset($allowed[$column])) return []; + $col = $allowed[$column]; + $limit = max(1, min(50, $limit)); + $db = traffic_db(); + + $conds = []; + if ($sinceTs > 0) $conds[] = 'ts >= ' . (int)$sinceTs; + if ($humansOnly) $conds[] = 'is_bot = 0'; + $where = $conds ? ('WHERE ' . implode(' AND ', $conds)) : ''; + // Empty labels → "(direct)" / "(unknown)" for display later + $sql = "SELECT CASE WHEN TRIM({$col}) = '' THEN '' ELSE {$col} END AS label, + COUNT(*) AS cnt + FROM hits {$where} + GROUP BY label + ORDER BY cnt DESC + LIMIT {$limit}"; + $rows = $db->query($sql)->fetchAll(); + $out = []; + foreach ($rows as $r) { + $label = (string)$r['label']; + if ($label === '') { + $label = $col === 'ref_host' ? '(direct / none)' : ($col === 'lang' ? '(unknown)' : '(unknown)'); + } + $out[] = ['label' => $label, 'count' => (int)$r['cnt']]; + } + return $out; +} + +/** + * Bot visitor breakdown (for admin bot chart / list). + * @return list<array{label:string,count:int}> + */ +function traffic_bot_breakdown(int $limit = 10, int $sinceTs = 0): array { + $limit = max(1, min(50, $limit)); + $db = traffic_db(); + $conds = ['is_bot = 1']; + if ($sinceTs > 0) $conds[] = 'ts >= ' . (int)$sinceTs; + $where = 'WHERE ' . implode(' AND ', $conds); + $sql = "SELECT CASE WHEN TRIM(visitor) = '' THEN 'Bot' ELSE visitor END AS label, + COUNT(*) AS cnt + FROM hits {$where} + GROUP BY label + ORDER BY cnt DESC + LIMIT {$limit}"; + $rows = $db->query($sql)->fetchAll(); + $out = []; + foreach ($rows as $r) { + $out[] = ['label' => (string)$r['label'], 'count' => (int)$r['cnt']]; + } + return $out; +} + +/** + * Bundle of series for the admin analytics charts. + * Human traffic only in main series; bots are a separate series/KPI. + * @return array<string,mixed> + */ +function traffic_analytics(): array { + $now = time(); + $weekAgo = $now - 7 * 86400; + return [ + 'generated_at' => $now, + 'daily' => traffic_hits_by_day(30), + 'hourly' => traffic_hits_by_hour(24), + 'visitors' => traffic_breakdown('visitor', 10, $weekAgo, true), + 'bots' => traffic_bot_breakdown(10, $weekAgo), + 'hosts' => traffic_breakdown('host', 10, $weekAgo, true), + 'langs' => traffic_breakdown('lang', 8, $weekAgo, true), + 'refs' => traffic_breakdown('ref_host', 10, $weekAgo, true), + 'top_pages' => array_map(static function ($p) { + return [ + 'label' => $p['host'] . $p['path'], + 'count' => (int)$p['hits'], + 'unique' => (int)$p['unique_sessions'], + 'bots' => (int)($p['bot_hits'] ?? 0), + ]; + }, traffic_top_pages(10)), + ]; +} diff --git a/partials/vibe.php b/partials/vibe.php new file mode 100644 index 0000000..84b148a --- /dev/null +++ b/partials/vibe.php @@ -0,0 +1,73 @@ +<?php +$vibe = file_exists(__DIR__ . '/../vibe.php') + ? require __DIR__ . '/../vibe.php' + : ['description' => '', 'url' => '', 'updated' => '']; + +$vibe_desc = htmlspecialchars($vibe['description'] ?? '', ENT_QUOTES, 'UTF-8'); +$vibe_url = $vibe['url'] ?? ''; +$safe_url = htmlspecialchars($vibe_url, ENT_QUOTES, 'UTF-8'); + +function vibe_detect_type(string $url): string { + if (empty($url)) return 'none'; + $path = strtolower(parse_url($url, PHP_URL_PATH) ?? ''); + $ext = pathinfo($path, PATHINFO_EXTENSION); + if (in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg','avif'])) return 'image'; + if (in_array($ext, ['mp4','webm','ogv','mov'])) return 'video'; + if (in_array($ext, ['mp3','flac','wav','ogg','aac','opus','m4a'])) return 'audio'; + if (preg_match('/(?:youtube\.com\/watch\?.*v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $url, $m)) return 'youtube:' . $m[1]; + if (preg_match('/vimeo\.com\/(\d+)/', $url, $m)) return 'vimeo:' . $m[1]; + return 'link'; +} + +$type = vibe_detect_type($vibe_url); +?> + +<section aria-labelledby="vibe-title"> + <h2 id="vibe-title">My Current Vibe</h2> + + <?php if ($vibe_desc): ?> + <p><?= $vibe_desc ?></p> + <?php endif; ?> + + <?php if ($vibe_url): ?> + + <?php if ($type === 'image'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <img src="<?= $safe_url ?>" alt="Current vibe" style="max-width:100%;height:auto;" loading="lazy"> + + <?php elseif ($type === 'video'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <video controls style="max-width:100%;" preload="none"> + <source src="<?= $safe_url ?>"> + </video> + + <?php elseif ($type === 'audio'): ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <audio controls preload="none" style="width:100%;"> + <source src="<?= $safe_url ?>"> + </audio> + + <?php elseif (str_starts_with($type, 'youtube:')): ?> + <?php $yt_id = substr($type, 8); ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <div style="position:relative;padding-top:56.25%;overflow:hidden;"> + <iframe src="https://www.youtube.com/embed/<?= htmlspecialchars($yt_id) ?>" + style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" + allowfullscreen loading="lazy"></iframe> + </div> + + <?php elseif (str_starts_with($type, 'vimeo:')): ?> + <?php $vimeo_id = substr($type, 6); ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <div style="position:relative;padding-top:56.25%;overflow:hidden;"> + <iframe src="https://player.vimeo.com/video/<?= htmlspecialchars($vimeo_id) ?>" + style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" + allowfullscreen loading="lazy"></iframe> + </div> + + <?php else: ?> + <p><a href="<?= $safe_url ?>" target="_blank" rel="noopener noreferrer"><?= $safe_url ?></a></p> + <?php endif; ?> + + <?php endif; ?> +</section> diff --git a/partials/vibe_config.php b/partials/vibe_config.php new file mode 100644 index 0000000..6a93013 --- /dev/null +++ b/partials/vibe_config.php @@ -0,0 +1,9 @@ +<?php +// vibe.php — current vibe config, edited via admin/vibe.php +// Do not edit manually unless you know what you're doing. + +return [ + 'description' => 'A throwback to the golden age of internet content.', + 'url' => 'https://summer2.ytmnd.com/', + 'updated' => '2025-01-01', +]; |
