Atlas modal: pose toggle + wiki descriptions
avian/api/cutout.php: read ?pose=N parameter. pose=1 (default) serves
<slug>.png, pose=2 serves <slug>-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).
This commit is contained in:
+17
-2
@@ -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 = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci));
|
||||||
$slug = trim((string)$slug, '-');
|
$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 {
|
function serve_png(string $path): void {
|
||||||
header('Content-Type: image/png');
|
header('Content-Type: image/png');
|
||||||
header('Cache-Control: public, max-age=86400');
|
header('Cache-Control: public, max-age=86400');
|
||||||
@@ -41,11 +47,20 @@ function serve_png(string $path): void {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Bundled illustration (the kachō-e PNG the repo ships with).
|
// 1. Bundled illustration with pose suffix (the kachō-e PNG the repo
|
||||||
$bundled = dirname(__DIR__) . "/assets/illustrations/$slug.png";
|
// ships with). 450+ species cover both perched + flight.
|
||||||
|
$bundled = dirname(__DIR__) . "/assets/illustrations/{$slug}{$poseSuffix}.png";
|
||||||
if (is_file($bundled) && filesize($bundled) > 1024) {
|
if (is_file($bundled) && filesize($bundled) > 1024) {
|
||||||
serve_png($bundled);
|
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
|
// 2. Bundled cutout (background-removed photo, fallback for species
|
||||||
// without an illustration).
|
// without an illustration).
|
||||||
$cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png";
|
$cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png";
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
// AvianVisitors - Wikipedia summary proxy.
|
||||||
|
//
|
||||||
|
// The detail modal calls /avian/api/wiki.php?sci=<name> 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,
|
||||||
|
]);
|
||||||
@@ -1856,7 +1856,7 @@
|
|||||||
// Wikipedia summary (description + genus / family).
|
// Wikipedia summary (description + genus / family).
|
||||||
var loadWiki = WIKI_CACHE[sci]
|
var loadWiki = WIKI_CACHE[sci]
|
||||||
? Promise.resolve(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;
|
WIKI_CACHE[sci] = j; return j;
|
||||||
});
|
});
|
||||||
loadWiki.then(function (j) {
|
loadWiki.then(function (j) {
|
||||||
|
|||||||
Reference in New Issue
Block a user