From 35eed2a8851878dec2cf4b21ea7ab5acfad88380 Mon Sep 17 00:00:00 2001 From: Twarner491 Date: Thu, 28 May 2026 12:16:08 -0700 Subject: [PATCH] Atlas modal: pose toggle + wiki descriptions avian/api/cutout.php: read ?pose=N parameter. pose=1 (default) serves .png, pose=2 serves -2.png, etc. Pose >1 missing falls back to pose-1 instead of skipping to the photo cutout so the flight tab still shows something recognisable. Pose clamped to 1-99 to block path-traversal payloads. avian/api/wiki.php (new): Wikipedia REST summary proxy. Returns {extract, thumbnail, title}. Thumbnail host pinned to wikimedia.org/ wikipedia.org to block SSRF. Cached for 24h. avian/frontend/apt.js: fetch wiki descriptions from ./avian/api/wiki.php (was hitting the worker's /api/wiki.json which 404'd in our overlay). --- avian/api/cutout.php | 19 +++++++++++++-- avian/api/wiki.php | 56 +++++++++++++++++++++++++++++++++++++++++++ avian/frontend/apt.js | 2 +- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 avian/api/wiki.php diff --git a/avian/api/cutout.php b/avian/api/cutout.php index d33334b..f17dad2 100644 --- a/avian/api/cutout.php +++ b/avian/api/cutout.php @@ -33,6 +33,12 @@ if (!preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) { $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci)); $slug = trim((string)$slug, '-'); +// pose=1 (default) is perched. pose=2 is flight. Clamp to a two-digit +// positive integer so a malformed ?pose= can't break the path. +$pose = (int)($_GET['pose'] ?? 1); +if ($pose < 1 || $pose > 99) $pose = 1; +$poseSuffix = $pose === 1 ? '' : "-$pose"; + function serve_png(string $path): void { header('Content-Type: image/png'); header('Cache-Control: public, max-age=86400'); @@ -41,11 +47,20 @@ function serve_png(string $path): void { exit; } -// 1. Bundled illustration (the kachō-e PNG the repo ships with). -$bundled = dirname(__DIR__) . "/assets/illustrations/$slug.png"; +// 1. Bundled illustration with pose suffix (the kachō-e PNG the repo +// ships with). 450+ species cover both perched + flight. +$bundled = dirname(__DIR__) . "/assets/illustrations/{$slug}{$poseSuffix}.png"; if (is_file($bundled) && filesize($bundled) > 1024) { serve_png($bundled); } +// Pose-2 missing? Fall back to pose-1 so the flight tab still shows +// the perched render instead of breaking to the photo fallback. +if ($pose !== 1) { + $fallback = dirname(__DIR__) . "/assets/illustrations/$slug.png"; + if (is_file($fallback) && filesize($fallback) > 1024) { + serve_png($fallback); + } +} // 2. Bundled cutout (background-removed photo, fallback for species // without an illustration). $cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png"; diff --git a/avian/api/wiki.php b/avian/api/wiki.php new file mode 100644 index 0000000..b05844a --- /dev/null +++ b/avian/api/wiki.php @@ -0,0 +1,56 @@ + for the species +// description. We hit Wikipedia's REST summary endpoint and return the +// extract + thumbnail. Cached at the browser + Caddy edge for 24 h +// because species descriptions don't really change. +// +// Pinned to wikimedia.org / wikipedia.org for the photo URL to block +// SSRF if Wikipedia ever returns a poisoned redirect target. + +declare(strict_types=1); +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: public, max-age=86400'); + +$sci = trim((string)($_GET['sci'] ?? '')); +if ($sci === '') { + http_response_code(400); + echo json_encode(['error' => 'sci required']); + exit; +} +if (!preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) { + http_response_code(400); + echo json_encode(['error' => 'invalid sci']); + exit; +} + +$ua = getenv('AV_USER_AGENT') ?: 'AvianVisitors/1.0 (+https://github.com/Twarner491/AvianVisitors)'; +$ctx = stream_context_create([ + 'http' => ['header' => "User-Agent: $ua\r\n", 'timeout' => 8], +]); +$url = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci); +$raw = @file_get_contents($url, false, $ctx); +if ($raw === false) { + echo json_encode(['extract' => null, 'thumbnail' => null]); + exit; +} +$j = json_decode($raw, true); +if (!is_array($j)) { + echo json_encode(['extract' => null, 'thumbnail' => null]); + exit; +} + +$thumb = $j['thumbnail']['source'] ?? null; +if ($thumb) { + $host = parse_url((string)$thumb, PHP_URL_HOST) ?: ''; + if (!preg_match('/(?:^|\.)(?:wikimedia\.org|wikipedia\.org)$/i', $host)) { + $thumb = null; + } +} + +echo json_encode([ + 'extract' => $j['extract'] ?? null, + 'thumbnail' => $thumb ? ['source' => $thumb] : null, + 'title' => $j['title'] ?? null, +]); diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js index b84a346..e835a89 100644 --- a/avian/frontend/apt.js +++ b/avian/frontend/apt.js @@ -1856,7 +1856,7 @@ // Wikipedia summary (description + genus / family). var loadWiki = WIKI_CACHE[sci] ? Promise.resolve(WIKI_CACHE[sci]) - : fetchJson('/api/wiki.json?sci=' + encodeURIComponent(sci)).then(function (j) { + : fetchJson('./avian/api/wiki.php?sci=' + encodeURIComponent(sci)).then(function (j) { WIKI_CACHE[sci] = j; return j; }); loadWiki.then(function (j) {