From 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 Mon Sep 17 00:00:00 2001 From: sillylaird Date: Thu, 3 Sep 2026 00:33:59 +0000 Subject: import live www.sillylaird.ca webroot --- partials/admin_vibe.php | 201 ++++++++++++++ partials/asset_version.php | 21 ++ partials/footer.php | 40 +++ partials/head_meta.php | 103 ++++++++ partials/header.php | 177 +++++++++++++ partials/header_search.php | 34 +++ partials/i18n.php | 40 +++ partials/partials_vibe.php | 212 +++++++++++++++ partials/proxy_helpers.php | 80 ++++++ partials/recent_static.php | 195 ++++++++++++++ partials/session.php | 15 ++ partials/site_nav_panel.php | 44 ++++ partials/site_status.php | 70 +++++ partials/traffic.php | 626 ++++++++++++++++++++++++++++++++++++++++++++ partials/vibe.php | 73 ++++++ partials/vibe_config.php | 9 + 16 files changed, 1940 insertions(+) create mode 100644 partials/admin_vibe.php create mode 100644 partials/asset_version.php create mode 100644 partials/footer.php create mode 100644 partials/head_meta.php create mode 100644 partials/header.php create mode 100644 partials/header_search.php create mode 100644 partials/i18n.php create mode 100644 partials/partials_vibe.php create mode 100644 partials/proxy_helpers.php create mode 100644 partials/recent_static.php create mode 100644 partials/session.php create mode 100644 partials/site_nav_panel.php create mode 100644 partials/site_status.php create mode 100644 partials/traffic.php create mode 100644 partials/vibe.php create mode 100644 partials/vibe_config.php (limited to 'partials') 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 @@ + '', '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 = " $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'; +} +?> + + + + + + Edit Current Vibe — SillyLaird + + + + + +
+ ← Back to admin +

Edit: My Current Vibe

+ + +

✓ Saved successfully.

+ + + +
+ +

+ +
+ + +
+
+ + +
+ +
+ + + + Detected type: + +
+ + +

Last saved:

+ + + +
+ + +
+

Current Preview

+ + +

+ Vibe preview + + +

+ + + +

+ + + + +

+
+ +
+ + + +

+
+ +
+ + +

+ +
+ + +
+ + 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 @@ + + 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 @@ + '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'); +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + 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 @@ + + \ 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 @@ + 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 @@ + '', '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'); +?> + + + +
+

+ + +

+ + + + + + Current vibe + + + +
+ +
+

+ Open video ↗ +

+ + + +

+ Open audio ↗ +

+ + + +
+ + +
+

+ Watch on ↗ +

+ + +

+ +

+ + + + +

Updated

+ + + +
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 @@ + 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 @@ += $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('' . $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 '
No posts to show right now.
'; + return; + } + $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + echo '
Posts (all)
'; + echo '
    '; + 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 '
  1. ' . $h($p['title'] ?? 'Untitled') . ''; + if ($iso) echo '
    '; + echo '
  2. '; + } + echo '
'; +} + +/** 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 '
No entries to show right now.
'; + return; + } + $h = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); + echo '
Entries (all)
'; + echo '
    '; + foreach ($entries as $e) { + echo '
  1. '; + if ($e['title']) echo '' . $h($e['title']) . ''; + if ($e['iso'] || $e['when']) { + echo '
    ' . $h($e['when'] ?: $e['iso']) . '
    '; + } + if ($e['body']) echo '
    ' . $h($e['body']) . '
    '; + $yr = $e['iso'] ? substr((string)$e['iso'], 0, 4) : ''; + if ($yr) echo 'more (' . $h($yr) . ') →'; + echo '
  2. '; + } + echo '
'; +} 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 @@ + 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 @@ + 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', '🗺'], +]; +?> +
+ +
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 @@ + 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'; + ?> +
+ Last updated: + +
+ 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> */ +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> */ +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 + */ +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 + */ +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 + */ +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 + */ +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 + */ +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 @@ + '', '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); +?> + +
+

My Current Vibe

+ + +

+ + + + + +

+ Current vibe + + +

+ + + +

+ + + + +

+
+ +
+ + + +

+
+ +
+ + +

+ + + +
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 @@ + 'A throwback to the golden age of internet content.', + 'url' => 'https://summer2.ytmnd.com/', + 'updated' => '2025-01-01', +]; -- cgit v1.2.3