fix(photos): bridge hash-named gallery 404s to local named set (black-down)

Public /photos/ vhost serves the descriptive-named admin photo set from local
disk since black:8081 photos-origin was decommissioned (2026-06-27), but the
deployed gallery bundle addresses photos by 12-hex content hash — every image
404s.

Add relink-photo-hashes.sh: extracts the name->hash map from the LIVE quinn.www
bundle and (re)creates <hash> -> <named> symlinks in the admin photo dir, so
both naming schemes resolve. Idempotent; self-corrects to whatever frontend is
deployed; becomes inert when a photos origin returns and the vhost reverts to
proxy_pass. Hooked into quinn.admin/deploy.sh step 4c after the photo rsync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-28 20:32:29 -04:00
parent b3cb0efd1d
commit 34048f1e1a
2 changed files with 120 additions and 0 deletions

View file

@ -222,6 +222,15 @@ if [[ -d "$PHOTOS_SRC" ]]; then
ssh "$REMOTE" "install -d -o www-data -g www-data ${REMOTE_PHOTOS}"
rsync -avz "${PHOTOS_SRC}/" "${REMOTE}:${REMOTE_PHOTOS}/"
ssh "$REMOTE" "chown -R www-data:www-data ${REMOTE_PHOTOS}"
# Black-down bridge: the public /photos/ vhost currently serves this admin
# named set from local disk (origin black:8081 is gone). The deployed gallery
# bundle requests 12-hex content-hash names, so regenerate <hash> -> <named>
# symlinks here, derived from the LIVE www bundle. Idempotent; becomes inert
# once a photos origin returns and the vhost reverts to proxy_pass.
# See scripts/relink-photo-hashes.sh for the full rationale + retirement note.
"${SCRIPT_DIR}/scripts/relink-photo-hashes.sh" "$REMOTE" "$REMOTE_PHOTOS" \
|| echo " WARNING: photo hash relink failed (gallery may 404 until re-run)." >&2
else
echo "==> [4c/10] WARNING: ${PHOTOS_SRC} not found on this host — skipping admin gallery photo sync." >&2
echo " Admin gallery thumbnails + variant lenses will 404 until the photo set is present." >&2

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
#
# relink-photo-hashes.sh — black-down emergency-fallback bridge for /photos/.
#
# WHY THIS EXISTS
# The public gallery (quinn.www) addresses every photo by a 12-hex content
# hash (e.g. /photos/33359fb60aab.jpeg). Those hashes are produced by the
# protect-photos pipeline at build time and baked into the deployed bundle as
# the `virtual:photo-hashes` name->hash map. In normal operation the hashed
# files are served by the photos origin (black:8081, track B) via the public
# vhost's `proxy_pass http://black_photos`.
#
# black was decommissioned 2026-06-27, so the public /photos/ location was
# re-pointed to local disk (`root /var/www/quinn.admin; try_files $uri =404;`)
# serving the DESCRIPTIVE-named admin photo set (cage-harness-purple.jpeg, …).
# The deployed bundle still requests HASHED names, so without a bridge every
# gallery image 404s.
#
# This script regenerates `<hash> -> <named-file>` symlinks inside the admin
# photo dir so both naming schemes resolve. The name->hash map is read from
# the LIVE quinn.www bundle, so the symlinks always track whatever frontend is
# actually deployed — no dependence on reproducing protect-photos hashes.
#
# WHEN IT RUNS
# Invoked by quinn.admin/deploy.sh step [4c] after the named photo set is
# rsynced (an additive rsync can add new named files whose hash aliases are
# missing). Safe to run standalone and repeatedly — it is idempotent.
#
# RETIREMENT
# When a photos origin returns and the public vhost's /photos/ location is
# reverted to proxy_pass/proxy_cache (see the .pre-blackdead-photoslocal-*
# nginx backup on vps-0), this bridge is no longer needed: the symlinks become
# inert and this step can be removed from deploy.sh.
#
set -euo pipefail
REMOTE="${1:-quinn-vps}"
REMOTE_PHOTOS="${2:-/var/www/quinn.admin/photos}"
SITE_URL="${PHOTOS_SITE_URL:-https://transquinnftw.com}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
echo "==> [relink-photo-hashes] Resolving live bundle hash map from ${SITE_URL} ..."
# 1. Find the deployed main JS bundle (Vite emits /assets/index-<hash>.js).
INDEX_HTML="$(curl -fsSL -A 'Mozilla/5.0' "${SITE_URL}/")"
JS_PATH="$(printf '%s' "$INDEX_HTML" \
| grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1)"
if [[ -z "$JS_PATH" ]]; then
echo " WARNING: could not locate /assets/index-*.js in ${SITE_URL} — skipping relink." >&2
exit 0
fi
curl -fsSL -A 'Mozilla/5.0' "${SITE_URL}${JS_PATH}" -o "${TMP}/bundle.js"
# 2. Extract the embedded name->hash photo map (the protect-photos manifest).
# Located by scanning for the first object literal whose values are 12-hex
# image filenames — robust to the minifier's variable name for the map.
node - "${TMP}/bundle.js" "${TMP}/photo-map.tsv" <<'NODE'
const fs = require('fs');
// argv: [node, '-', <bundle>, <out>]'-' (stdin script marker) occupies argv[1].
const [, , src, out] = process.argv;
const js = fs.readFileSync(src, 'utf8');
const HASH_VAL = /^[0-9a-f]{12}\.(jpe?g|webp|png)$/;
// Find every `={` that begins a candidate object, brace-match it, JSON-parse,
// and keep the one whose values are all hashed image filenames.
let best = null;
for (let i = 0; i < js.length - 1; i++) {
if (js[i] !== '=' || js[i + 1] !== '{') continue;
let depth = 0, j = i + 1;
for (; j < js.length; j++) {
const c = js[j];
if (c === '{') depth++;
else if (c === '}') { depth--; if (depth === 0) { j++; break; } }
}
let obj;
try { obj = JSON.parse(js.slice(i + 1, j)); } catch { continue; }
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) continue;
const keys = Object.keys(obj);
if (keys.length < 10) continue;
if (!keys.every(k => typeof obj[k] === 'string' && HASH_VAL.test(obj[k]))) continue;
if (!best || keys.length > Object.keys(best).length) best = obj;
}
if (!best) { console.error('no photo hash map found in bundle'); process.exit(2); }
const lines = Object.entries(best).map(([name, hash]) => `${name}\t${hash}`);
fs.writeFileSync(out, lines.join('\n') + '\n');
console.error(` extracted ${lines.length} name->hash entries`);
NODE
# 3. Ship the map and (re)create the symlinks on the remote, only where the
# named target file is actually present. Idempotent: ln -sf overwrites.
scp -q "${TMP}/photo-map.tsv" "${REMOTE}:/tmp/photo-map.tsv"
ssh "$REMOTE" "REMOTE_PHOTOS='${REMOTE_PHOTOS}' bash -s" <<'REMOTE_SH'
set -euo pipefail
cd "$REMOTE_PHOTOS"
created=0; missing=0
while IFS=$'\t' read -r name hash; do
[ -z "${name:-}" ] && continue
if [ -f "$name" ]; then
ln -sf "$name" "$hash"
created=$((created + 1))
else
missing=$((missing + 1))
fi
done < /tmp/photo-map.tsv
rm -f /tmp/photo-map.tsv
chown -h www-data:www-data "$REMOTE_PHOTOS"/*.jpeg "$REMOTE_PHOTOS"/*.jpg "$REMOTE_PHOTOS"/*.webp "$REMOTE_PHOTOS"/*.png 2>/dev/null || true
echo " relinked ${created} hash aliases (${missing} with no named target on disk)"
REMOTE_SH
echo "==> [relink-photo-hashes] Done."