aboutsummaryrefslogtreecommitdiffstats
path: root/api
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /api
downloadwww-main.tar.gz
www-main.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rw-r--r--api/guestbook-proxy.php29
-rw-r--r--api/guestbook-recent.php78
-rw-r--r--api/hit.php148
-rw-r--r--api/lastfm.php110
-rw-r--r--api/nowplaying.php47
-rw-r--r--api/traffic-stats.php95
6 files changed, 507 insertions, 0 deletions
diff --git a/api/guestbook-proxy.php b/api/guestbook-proxy.php
new file mode 100644
index 0000000..63f1446
--- /dev/null
+++ b/api/guestbook-proxy.php
@@ -0,0 +1,29 @@
+<?php
+// Proxy for guestbook comments — sidesteps browser CORS restrictions.
+// PHP fetches happen server-to-server with no CORS enforcement.
+
+header('Content-Type: text/html; charset=utf-8');
+header('Cache-Control: no-store, no-cache');
+header('X-Robots-Tag: noindex');
+
+$url = 'https://guestbook.sillylaird.ca/guestbook-comments.php';
+$ctx = stream_context_create([
+ 'http' => [
+ 'timeout' => 8,
+ 'user_agent' => 'SillyLaird-Proxy/1.0',
+ 'ignore_errors' => true,
+ ],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ ],
+]);
+
+$html = @file_get_contents($url, false, $ctx);
+
+if ($html === false) {
+ http_response_code(502);
+ exit;
+}
+
+echo $html;
diff --git a/api/guestbook-recent.php b/api/guestbook-recent.php
new file mode 100644
index 0000000..c4ef59e
--- /dev/null
+++ b/api/guestbook-recent.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Guestbook recent comments — JSON proxy for homepage inline display.
+ * Fetches from guestbook.sillylaird.ca/api.php (server-to-server, no CORS).
+ */
+require_once __DIR__ . '/../partials/proxy_helpers.php';
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: public, max-age=90');
+
+$limit = min(max((int)($_GET['limit'] ?? 8), 1), 25);
+
+$cacheDir = sys_get_temp_dir() . '/sillylaird_guestbook';
+if (!is_dir($cacheDir)) {
+ @mkdir($cacheDir, 0700, true);
+}
+$cacheFile = $cacheDir . '/recent_' . $limit . '.json';
+$freshTtl = 90;
+$staleTtl = 3600;
+
+$emit = static function (string $json, int $code = 200): void {
+ http_response_code($code);
+ echo $json;
+ exit;
+};
+
+if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $freshTtl) {
+ $emit((string)file_get_contents($cacheFile));
+}
+
+if (!proxy_rate_limit('guestbook_recent', 40, 60)) {
+ if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $staleTtl) {
+ $emit((string)file_get_contents($cacheFile));
+ }
+ $emit(json_encode(['error' => true, 'message' => 'rate limited']), 429);
+}
+
+$url = 'https://guestbook.sillylaird.ca/api.php?limit=' . $limit;
+$ctx = stream_context_create([
+ 'http' => [
+ 'timeout' => 8,
+ 'user_agent' => 'SillyLaird-GuestbookProxy/1.0',
+ 'ignore_errors' => true,
+ ],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ ],
+]);
+
+$raw = @file_get_contents($url, false, $ctx);
+if ($raw === false) {
+ if (is_file($cacheFile)) {
+ $emit((string)file_get_contents($cacheFile));
+ }
+ $emit(json_encode(['error' => true, 'message' => 'upstream unavailable']), 502);
+}
+
+$data = json_decode($raw, true);
+if (!is_array($data) || !isset($data['entries']) || !is_array($data['entries'])) {
+ if (is_file($cacheFile)) {
+ $emit((string)file_get_contents($cacheFile));
+ }
+ $emit(json_encode(['error' => true, 'message' => 'invalid upstream response']), 502);
+}
+
+$out = json_encode([
+ 'entries' => array_slice($data['entries'], 0, $limit),
+ 'total' => (int)($data['total'] ?? count($data['entries'])),
+ 'fetched' => time(),
+], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+$tmp = $cacheFile . '.tmp';
+if (@file_put_contents($tmp, $out, LOCK_EX) !== false) {
+ @rename($tmp, $cacheFile);
+}
+
+$emit($out); \ No newline at end of file
diff --git a/api/hit.php b/api/hit.php
new file mode 100644
index 0000000..03c3965
--- /dev/null
+++ b/api/hit.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Privacy-friendly page hit logger.
+ *
+ * Stores: host, path, coarse browser/OS label, language, referrer host,
+ * local visitor number, timestamp.
+ * Never stores: IP addresses, full User-Agent strings, cookies, or query params.
+ *
+ * Called by assets/js/visit-counter.js via sendBeacon/fetch from any
+ * *.sillylaird.ca page (CORS allowlisted).
+ */
+
+declare(strict_types=1);
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+header('X-Content-Type-Options: nosniff');
+header('Referrer-Policy: no-referrer');
+
+// --- CORS: only sillylaird.ca hosts (and www) may POST hits -----------------
+$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
+$originHost = '';
+$corsOk = false;
+if (is_string($origin) && $origin !== '') {
+ $oh = parse_url($origin, PHP_URL_HOST);
+ if (is_string($oh) && (
+ $oh === 'sillylaird.ca'
+ || $oh === 'www.sillylaird.ca'
+ || str_ends_with($oh, '.sillylaird.ca')
+ )) {
+ $originHost = $oh;
+ header('Access-Control-Allow-Origin: ' . $origin);
+ header('Vary: Origin');
+ header('Access-Control-Allow-Methods: POST, OPTIONS');
+ header('Access-Control-Allow-Headers: Content-Type');
+ header('Access-Control-Max-Age: 86400');
+ $corsOk = true;
+ } else {
+ // Browser sent a foreign Origin — refuse entirely (no logging).
+ // Use 400 (not 403): www nginx maps 403 → /403.php via fastcgi_intercept_errors.
+ http_response_code(400);
+ echo json_encode(['ok' => false, 'err' => 'origin not allowed']);
+ exit;
+ }
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
+ if ($corsOk) {
+ http_response_code(204);
+ } else {
+ http_response_code(400);
+ }
+ exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['ok' => false, 'err' => 'POST only']);
+ exit;
+}
+
+require_once __DIR__ . '/../partials/proxy_helpers.php';
+require_once __DIR__ . '/../partials/traffic.php';
+
+// Rate limit abuse without logging the IP into the traffic DB.
+if (!proxy_rate_limit('hit_log', 60, 60)) {
+ http_response_code(429);
+ echo json_encode(['ok' => false, 'err' => 'rate limited']);
+ exit;
+}
+
+$raw = file_get_contents('php://input');
+$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
+if (!is_array($data)) {
+ // Also accept form-urlencoded beacons
+ $data = $_POST;
+}
+
+$clientHost = trim((string)($data['host'] ?? ''));
+$path = (string)($data['path'] ?? '/');
+$localCount = (int)($data['local_count'] ?? 0);
+$isNew = !empty($data['unique']) || !empty($data['is_new']);
+$lang = trim((string)($data['lang'] ?? ''));
+$ref = trim((string)($data['ref'] ?? ''));
+
+// Resolve host without trusting arbitrary input.
+// Prefer CORS Origin (browser-set), then allowed client host, then request Host.
+$host = '';
+if ($originHost !== '' && traffic_host_allowed($originHost)) {
+ $host = $originHost;
+} elseif (traffic_host_allowed($clientHost)) {
+ $host = $clientHost;
+} else {
+ $rh = (string)($_SERVER['HTTP_HOST'] ?? '');
+ if (traffic_host_allowed($rh)) {
+ $host = $rh;
+ }
+}
+if ($host === '' || !traffic_host_allowed($host)) {
+ http_response_code(400);
+ echo json_encode(['ok' => false, 'err' => 'host not allowed']);
+ exit;
+}
+
+$path = traffic_normalize_path($path);
+if ($path === '' || strlen($path) > 500) {
+ http_response_code(400);
+ echo json_encode(['ok' => false, 'err' => 'bad path']);
+ exit;
+}
+
+// Skip noisy/internal paths even if a client tries to report them.
+if (preg_match('#^/(admin|api|partials|locales|tools|docs)(/|$)#i', $path)) {
+ echo json_encode(['ok' => true, 'skipped' => true]);
+ exit;
+}
+
+$uaHeader = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
+$isBot = traffic_is_bot($uaHeader);
+$visitor = traffic_visitor_label($uaHeader); // e.g. "Firefox · Linux" or "Bot · Google" — not an IP
+$refHost = traffic_ref_host($ref !== '' ? $ref : (string)($_SERVER['HTTP_REFERER'] ?? ''));
+$lang = traffic_lang($lang !== '' ? $lang : (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''));
+$localCount = max(0, min($localCount, 99999999));
+
+try {
+ $result = traffic_record_hit([
+ 'host' => $host,
+ 'path' => $path,
+ 'visitor' => $visitor,
+ 'lang' => $lang,
+ 'ref_host' => $refHost,
+ 'local_count' => $localCount,
+ // Bots never count as unique human sessions.
+ 'is_new' => ($isNew && !$isBot) ? 1 : 0,
+ 'is_bot' => $isBot ? 1 : 0,
+ ]);
+ echo json_encode([
+ 'ok' => true,
+ 'page' => $result['page_key'],
+ 'hits' => $result['hits'],
+ 'uniq' => $result['unique_sessions'],
+ 'bots' => $result['bot_hits'] ?? 0,
+ 'bot' => !empty($result['is_bot']),
+ ]);
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode(['ok' => false, 'err' => 'store failed']);
+}
diff --git a/api/lastfm.php b/api/lastfm.php
new file mode 100644
index 0000000..5ec583b
--- /dev/null
+++ b/api/lastfm.php
@@ -0,0 +1,110 @@
+<?php
+require_once __DIR__ . '/../partials/proxy_helpers.php';
+
+if (!headers_sent()) {
+ header('Content-Type: application/json');
+ header('Cache-Control: no-cache, must-revalidate');
+}
+ob_start();
+
+$cacheDir = sys_get_temp_dir() . '/sillylaird_lastfm';
+if (!is_dir($cacheDir)) @mkdir($cacheDir, 0700, true);
+$cacheFile = $cacheDir . '/recent.json';
+$freshTtl = 10; // serve cache without hitting upstream
+$staleTtl = PHP_INT_MAX; // on upstream failure, always fall back to cache
+
+$serveCache = function (string $file) {
+ $body = @file_get_contents($file);
+ if ($body === false) return false;
+ ob_end_clean();
+ echo $body;
+ return true;
+};
+
+if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $freshTtl) {
+ if ($serveCache($cacheFile)) exit;
+}
+
+if (!proxy_rate_limit('lastfm_api', 60, 60)) {
+ if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $staleTtl) {
+ if ($serveCache($cacheFile)) exit;
+ }
+ ob_end_clean();
+ http_response_code(429);
+ echo json_encode(['error' => true, 'message' => 'rate limited']);
+ exit;
+}
+
+$apiKey = lastfm_api_key();
+$user = 'SillyLaird';
+$limit = 5;
+
+$url = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=$user&api_key=$apiKey&format=json&limit=$limit";
+
+$ch = curl_init();
+curl_setopt($ch, CURLOPT_URL, $url);
+curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+
+$response = curl_exec($ch);
+$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+$error = curl_error($ch);
+curl_close($ch);
+
+try {
+ if ($response === false) {
+ throw new Exception("cURL Error: " . $error);
+ }
+
+ if ($httpCode !== 200) {
+ throw new Exception("Last.fm API returned HTTP $httpCode");
+ }
+
+ $data = json_decode($response, true);
+
+ if (isset($data['error'])) {
+ throw new Exception("Last.fm Error: " . $data['message']);
+ }
+
+ if (!isset($data['recenttracks']['track'])) {
+ throw new Exception("No tracks found.");
+ }
+
+ $tracks = $data['recenttracks']['track'];
+ if (isset($tracks['name'])) {
+ $tracks = [$tracks];
+ }
+
+ $results = [];
+ foreach ($tracks as $track) {
+ $results[] = [
+ 'name' => $track['name'],
+ 'artist' => $track['artist']['#text'],
+ 'url' => $track['url'],
+ 'image' => isset($track['image'][2]['#text']) ? $track['image'][2]['#text'] : '',
+ 'nowplaying' => isset($track['@attr']['nowplaying']) && $track['@attr']['nowplaying'] === 'true',
+ ];
+ }
+
+ $json = json_encode($results);
+ $tmp = $cacheFile . '.tmp';
+ if (@file_put_contents($tmp, $json, LOCK_EX) !== false) {
+ @rename($tmp, $cacheFile);
+ }
+ ob_end_clean();
+ echo $json;
+
+} catch (Exception $e) {
+ if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $staleTtl) {
+ if ($serveCache($cacheFile)) exit;
+ }
+ ob_end_clean();
+ http_response_code(500);
+ echo json_encode([
+ 'error' => true,
+ 'message' => $e->getMessage(),
+ ]);
+}
diff --git a/api/nowplaying.php b/api/nowplaying.php
new file mode 100644
index 0000000..1faf016
--- /dev/null
+++ b/api/nowplaying.php
@@ -0,0 +1,47 @@
+<?php
+require_once __DIR__ . '/../partials/proxy_helpers.php';
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: public, max-age=10');
+
+$cacheFile = sys_get_temp_dir() . '/sillylaird_nowplaying.json';
+$ttl = 10;
+
+if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $ttl) {
+ readfile($cacheFile);
+ exit;
+}
+
+if (!proxy_rate_limit('nowplaying', 30, 60)) {
+ http_response_code(429);
+ echo json_encode(['error' => 'rate limited']);
+ exit;
+}
+
+$ctx = stream_context_create(['http' => [
+ 'timeout' => 4,
+ 'header' => "User-Agent: sillylaird-nowplaying/1.0\r\n",
+]]);
+
+$raw = @file_get_contents('https://radio.sillylaird.ca/status-json.xsl', false, $ctx);
+if ($raw === false) {
+ if (is_file($cacheFile)) { readfile($cacheFile); exit; }
+ http_response_code(502);
+ echo json_encode(['error' => 'upstream']);
+ exit;
+}
+
+$data = json_decode($raw, true);
+$src = $data['icestats']['source'] ?? null;
+if (is_array($src) && isset($src[0])) $src = $src[0];
+
+$out = json_encode([
+ 'title' => $src['title'] ?? ($src['yp_currently_playing'] ?? null),
+ 'name' => $src['server_name'] ?? null,
+ 'online' => isset($src['title']) || isset($src['yp_currently_playing']),
+ 'fetched' => time(),
+]);
+
+@file_put_contents($cacheFile . '.tmp', $out, LOCK_EX);
+@rename($cacheFile . '.tmp', $cacheFile);
+echo $out;
diff --git a/api/traffic-stats.php b/api/traffic-stats.php
new file mode 100644
index 0000000..f8fd70a
--- /dev/null
+++ b/api/traffic-stats.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Admin-only live traffic stats (JSON).
+ * Used by /admin.php?section=traffic to poll and redraw charts in realtime.
+ *
+ * Requires the shared admin session. Never stores or returns IPs.
+ */
+
+declare(strict_types=1);
+
+require_once __DIR__ . '/../partials/session.php';
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store, no-cache, must-revalidate');
+header('X-Content-Type-Options: nosniff');
+header('Referrer-Policy: no-referrer');
+header('X-Robots-Tag: noindex');
+
+if (empty($_SESSION['admin'])) {
+ http_response_code(401);
+ echo json_encode(['ok' => false, 'err' => 'auth required']);
+ exit;
+}
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
+ http_response_code(405);
+ echo json_encode(['ok' => false, 'err' => 'GET only']);
+ exit;
+}
+
+require_once __DIR__ . '/../partials/traffic.php';
+
+try {
+ $db = traffic_db();
+ $latestHitId = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM hits')->fetchColumn();
+ $hitCount = (int)$db->query('SELECT COUNT(*) FROM hits')->fetchColumn();
+
+ $summary = traffic_summary();
+ $analytics = traffic_analytics();
+ $top = traffic_top_pages(80);
+ $recent = traffic_recent(150);
+
+ // Normalize recent rows for the client (bool is_bot, formatted fields optional).
+ $recentOut = [];
+ foreach ($recent as $r) {
+ $isBot = !empty($r['is_bot']) || traffic_visitor_is_bot((string)($r['visitor'] ?? ''));
+ $recentOut[] = [
+ 'id' => (int)($r['id'] ?? 0),
+ 'ts' => (int)($r['ts'] ?? 0),
+ 'host' => (string)($r['host'] ?? ''),
+ 'path' => (string)($r['path'] ?? ''),
+ 'visitor' => (string)($r['visitor'] ?? ''),
+ 'lang' => (string)($r['lang'] ?? ''),
+ 'ref_host' => (string)($r['ref_host'] ?? ''),
+ 'local_count' => (int)($r['local_count'] ?? 0),
+ 'is_new' => !empty($r['is_new']) ? 1 : 0,
+ 'is_bot' => $isBot ? 1 : 0,
+ ];
+ }
+
+ $topOut = [];
+ foreach ($top as $p) {
+ $topOut[] = [
+ 'page_key' => (string)($p['page_key'] ?? ''),
+ 'host' => (string)($p['host'] ?? ''),
+ 'path' => (string)($p['path'] ?? ''),
+ 'hits' => (int)($p['hits'] ?? 0),
+ 'unique_sessions' => (int)($p['unique_sessions'] ?? 0),
+ 'bot_hits' => (int)($p['bot_hits'] ?? 0),
+ 'last_local_count' => (int)($p['last_local_count'] ?? 0),
+ 'last_visitor' => (string)($p['last_visitor'] ?? ''),
+ 'last_hit' => (int)($p['last_hit'] ?? 0),
+ ];
+ }
+
+ // Fingerprint so the client can skip redraw when nothing changed.
+ $rev = $latestHitId . ':' . $hitCount
+ . ':' . (int)($summary['total_hits'] ?? 0)
+ . ':' . (int)($summary['bot_hits'] ?? 0)
+ . ':' . (int)($summary['today'] ?? 0)
+ . ':' . (int)($summary['bot_today'] ?? 0);
+
+ echo json_encode([
+ 'ok' => true,
+ 'rev' => $rev,
+ 'server_time' => time(),
+ 'summary' => $summary,
+ 'analytics' => $analytics,
+ 'top' => $topOut,
+ 'recent' => $recentOut,
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode(['ok' => false, 'err' => 'store failed']);
+}