From f41a2d4f3c89610953d9f37c01330c31f4bbb7b1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 6 Aug 2026 08:57:53 -0700 Subject: [PATCH] Add whistlegraph-thumbnail poster recovery endpoint Archive records whose durable CDN image is missing get a stable local URL that redirects to the CDN image or a fresh signed TikTok poster. Includes the Caddy route, og function updates, and specs for both. --- lith/Caddyfile | 18 ++- spec/whistlegraph-og-spec.mjs | 27 ++++ spec/whistlegraph-thumbnail-spec.mjs | 52 ++++++++ system/netlify/functions/whistlegraph-og.mjs | 123 ++++++++++++------ .../functions/whistlegraph-thumbnail.mjs | 81 ++++++++++++ system/public/whistlegraph.org/index.html | 18 ++- 6 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 spec/whistlegraph-og-spec.mjs create mode 100644 spec/whistlegraph-thumbnail-spec.mjs create mode 100644 system/netlify/functions/whistlegraph-thumbnail.mjs diff --git a/lith/Caddyfile b/lith/Caddyfile index 3012d6de1..89dbf631b 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -200,6 +200,11 @@ handle /api/whistlegraph-query* { import lith_proxy } + # Stable poster URL for archive records. The function redirects to the + # durable CDN image or, for older gaps, a fresh signed TikTok poster. + handle /api/whistlegraph-thumbnail* { + import lith_proxy + } # Auth0 returns to the bare origin. Only a root request carrying its # transaction state is served by the Desk; ordinary / remains the site. @wgcallback { @@ -226,12 +231,17 @@ header Access-Control-Allow-Origin "*" file_server } - # Bare / deep links (e.g. /imab, /clth) go to lith, which returns - # index.html with THAT work's og:title / og:image / og:video injected — - # so iMessage, Slack, Twitter, etc. unfurl the specific whistlegraph - # instead of one generic card. Unknown codes fall back to the plain page. + # Bare / and stable /post/ deep links go to lith, which returns + # index.html with that work/post's og:title / og:image / og:video injected + # so iMessage, Slack, Twitter, etc. unfurl the specific record instead of + # one generic card. Unknown records fall back to the plain page. # Everything with a dot (index.html, *.json, *.jpg, favicon) skips this # matcher and is served straight off disk. + @wgpost path_regexp wgpost ^/post/([0-9]+)$ + handle @wgpost { + rewrite * /api/whistlegraph-og?post={re.wgpost.1} + import lith_proxy + } @wgcode path_regexp wgcode ^/([A-Za-z0-9]+)$ handle @wgcode { rewrite * /api/whistlegraph-og?code={re.wgcode.1} diff --git a/spec/whistlegraph-og-spec.mjs b/spec/whistlegraph-og-spec.mjs new file mode 100644 index 000000000..ab0eacce4 --- /dev/null +++ b/spec/whistlegraph-og-spec.mjs @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { handler } from "../system/netlify/functions/whistlegraph-og.mjs"; + +describe("Whistlegraph link previews", () => { + it("routes stable archive-post URLs through the preview handler", () => { + const caddy = readFileSync(new URL("../lith/Caddyfile", import.meta.url), "utf8"); + expect(caddy).toContain("@wgpost path_regexp wgpost ^/post/([0-9]+)$"); + expect(caddy).toContain("rewrite * /api/whistlegraph-og?post={re.wgpost.1}"); + }); + + it("unfurls a video post with its own title, poster, video, and canonical URL", async () => { + const id = "6890414103169387781"; + const response = await handler({ queryStringParameters: { post: id } }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(''); + expect(response.body).toContain(``); + expect(response.body).toContain(``); + expect(response.body).toContain(``); + }); + + it("uses the shared poster fallback on archive-post detail pages", () => { + const page = readFileSync(new URL("../system/public/whistlegraph.org/index.html", import.meta.url), "utf8"); + expect(page).toContain('D.video.style.display=""; D.video.poster=videoPoster(p); D.video.src=p.src;'); + expect(page).toContain("The archive currently preserves this post's sound and poster, but not its original moving picture."); + }); +}); diff --git a/spec/whistlegraph-thumbnail-spec.mjs b/spec/whistlegraph-thumbnail-spec.mjs new file mode 100644 index 000000000..01ac91e2c --- /dev/null +++ b/spec/whistlegraph-thumbnail-spec.mjs @@ -0,0 +1,52 @@ +import { createHandler } from "../system/netlify/functions/whistlegraph-thumbnail.mjs"; + +const event = (id, method = "GET") => ({ httpMethod: method, queryStringParameters: { id } }); + +describe("Whistlegraph thumbnail recovery", () => { + it("keeps a durable canonical poster when one exists", async () => { + let fetched = false; + const handler = createHandler({ + loadPostsFn: () => new Map([["1234567890", { + id: "1234567890", + platform: "tiktok", + media: "video", + url: "https://www.tiktok.com/@whistlegraph/video/1234567890", + thumb: "https://assets.aesthetic.computer/post.jpg", + }]]), + fetchFn: async () => { fetched = true; }, + }); + const response = await handler(event("1234567890")); + expect(response.statusCode).toBe(302); + expect(response.headers.Location).toBe("https://assets.aesthetic.computer/post.jpg"); + expect(fetched).toBeFalse(); + }); + + it("recovers a fresh signed TikTok poster for a known missing asset", async () => { + const handler = createHandler({ + loadPostsFn: () => new Map([["6747917653291175174", { + id: "6747917653291175174", + platform: "tiktok", + media: "audio", + url: "https://www.tiktok.com/@whistlegraph/video/6747917653291175174", + thumb: null, + }]]), + fetchFn: async (_url, options = {}) => options.method === "HEAD" + ? { ok: false, status: 403 } + : { + ok: true, + status: 200, + json: async () => ({ thumbnail_url: "https://p16-common-sign.tiktokcdn-us.com/poster.jpg?signature=test" }), + }, + nowFn: () => 1, + }); + const response = await handler(event("6747917653291175174")); + expect(response.statusCode).toBe(302); + expect(response.headers.Location).toContain("tiktokcdn-us.com/poster.jpg"); + }); + + it("does not proxy arbitrary URLs or unknown post IDs", async () => { + const handler = createHandler({ loadPostsFn: () => new Map() }); + expect((await handler(event("not-a-post"))).statusCode).toBe(400); + expect((await handler(event("1234567890"))).statusCode).toBe(404); + }); +}); diff --git a/system/netlify/functions/whistlegraph-og.mjs b/system/netlify/functions/whistlegraph-og.mjs index 6963e9110..1b4315543 100644 --- a/system/netlify/functions/whistlegraph-og.mjs +++ b/system/netlify/functions/whistlegraph-og.mjs @@ -1,16 +1,19 @@ -// 🎠 whistlegraph.org per-code link previews (Open Graph) +// 🎠 whistlegraph.org work + archive-post link previews (Open Graph) // // The whistlegraph.org index is a static SPA: Caddy serves one index.html for // every / deep link, so link-unfurlers (iMessage, Slack, Twitter, …) all // see the SAME generic card. This function fills that gap — Caddy proxies bare -// code paths here, and we return index.html with that work's own og:title, -// og:image (its thumbnail) and og:video (the take's mp4) injected. Humans still -// get the full app; only the is rewritten. +// code and /post/ paths here, and we return index.html with that record's +// own og:title, og:image and (when it is a real video) og:video injected. Humans +// still get the full app; only the is rewritten. // // Wired in lith/Caddyfile under @whistlegraph: // @wgcode path_regexp wgcode ^/([A-Za-z0-9]+)$ // handle @wgcode { rewrite * /api/whistlegraph-og?code={re.wgcode.1} // reverse_proxy localhost:8888 } +// @wgpost path_regexp wgpost ^/post/([0-9]+)$ +// handle @wgpost { rewrite * /api/whistlegraph-og?post={re.wgpost.1} +// reverse_proxy localhost:8888 } import { readFileSync, statSync } from "fs"; import { fileURLToPath } from "url"; @@ -19,22 +22,36 @@ import { dirname, join } from "path"; const DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "whistlegraph.org"); const INDEX = join(DIR, "index.html"); const GRAPHS = join(DIR, "graphs.json"); +const POSTS = join(DIR, "posts.json"); +const POSTER_ENDPOINT = "https://whistlegraph.org/api/whistlegraph-thumbnail"; const DEFAULT_IMG = "https://assets.aesthetic.computer/whistlegraph/butterfly-cosplayer/butterfly-cosplayer.webp"; -// Cache index.html + the code→work map, refreshed when either file's mtime moves -// (a curation deploy rewrites graphs.json, so previews track it without a restart). -let cache = { indexMtime: 0, graphsMtime: 0, html: "", byCode: null, aliases: null }; +// Cache index.html and both record maps. Curation deploys rewrite the generated +// JSON, so mtimes let previews follow them without a process restart. +let cache = { indexMtime: 0, graphsMtime: 0, postsMtime: 0, html: "", byCode: null, byPost: null, aliases: null }; function load() { const im = statSync(INDEX).mtimeMs; const gm = statSync(GRAPHS).mtimeMs; - if (im !== cache.indexMtime || gm !== cache.graphsMtime || !cache.byCode) { + const pm = statSync(POSTS).mtimeMs; + if (im !== cache.indexMtime || gm !== cache.graphsMtime || pm !== cache.postsMtime || !cache.byCode || !cache.byPost) { const html = readFileSync(INDEX, "utf-8"); - const data = JSON.parse(readFileSync(GRAPHS, "utf-8")); - const works = Array.isArray(data) ? data : data.graphs || []; + const graphData = JSON.parse(readFileSync(GRAPHS, "utf-8")); + const postData = JSON.parse(readFileSync(POSTS, "utf-8")); + const works = Array.isArray(graphData) ? graphData : graphData.graphs || []; + const posts = Array.isArray(postData) ? postData : postData.posts || []; const byCode = new Map(works.map((w) => [w.code, w])); - cache = { indexMtime: im, graphsMtime: gm, html, byCode, aliases: data.aliases || {} }; + const byPost = new Map(posts.map((p) => [String(p.id), p])); + cache = { + indexMtime: im, + graphsMtime: gm, + postsMtime: pm, + html, + byCode, + byPost, + aliases: graphData.aliases || {}, + }; } return cache; } @@ -46,8 +63,47 @@ const esc = (s) => const videoFor = (thumb) => typeof thumb === "string" && /\/posts\/\d+\.jpg$/.test(thumb) ? thumb.replace(/\.jpg$/, ".mp4") : null; +const postTitle = (post) => { + const text = String(post?.desc || "").trim(); + if (text) return text.length > 120 ? `${text.slice(0, 117)}…` : text; + return `Whistlegraph archive post ${post?.id || ""}`.trim(); +}; + +const postImage = (post) => post?.thumb || ( + post?.platform === "tiktok" && /^\d+$/.test(String(post?.id || "")) + ? `${POSTER_ENDPOINT}?id=${encodeURIComponent(post.id)}` + : DEFAULT_IMG +); + +function injectPreview(html, { title, description, image, video, url }) { + return html + .replace(/[^<]*<\/title>/, `<title>${esc(title)} — Whistlegraph`) + .replace(/]*>/, ``) + .replace(/]*>/, ``) + .replace( + /]*>/, + [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ...(video + ? [ + ``, + ``, + ``, + ] + : []), + ].join("\n"), + ); +} + export const handler = async (event) => { const code = (event.queryStringParameters?.code || "").trim(); + const postId = (event.queryStringParameters?.post || "").trim(); let store; try { store = load(); @@ -57,13 +113,27 @@ export const handler = async (event) => { return { statusCode: 302, headers: { location: "/index.html" }, body: "" }; } let html = store.html; + const post = store.byPost.get(postId); + if (post) { + const title = postTitle(post); + const facts = [post.platform || "archive", post.date, post.views != null ? `${Number(post.views).toLocaleString("en-US")} views` : null] + .filter(Boolean) + .join(" · "); + html = injectPreview(html, { + title, + description: `Whistlegraph archive post · ${facts}`, + image: postImage(post), + video: post.media !== "audio" ? post.src || null : null, + url: `https://whistlegraph.org/post/${encodeURIComponent(post.id)}`, + }); + } const alias = store.aliases[code]; if (alias && store.byCode.has(alias)) { return { statusCode: 302, headers: { location: `/${encodeURIComponent(alias)}` }, body: "" }; } const work = store.byCode.get(code); - if (work) { + if (!post && work) { const title = work.title || "Whistlegraph"; const by = work.by && work.by !== "Whistlegraph" ? ` by ${work.by}` : ""; const yr = work.year ? ` (${work.year})` : ""; @@ -72,34 +142,7 @@ export const handler = async (event) => { const vid = videoFor(work.thumb); const url = `https://whistlegraph.org/${code}`; - html = html - .replace(/[^<]*<\/title>/, `<title>${esc(title)} — Whistlegraph`) - .replace( - /]*>/, - ``, - ) - .replace( - /]*>/, - ``, - ) - .replace( - /]*>/, - [ - ``, - ``, - ``, - ``, - ``, - ``, - ...(vid - ? [ - ``, - ``, - ``, - ] - : []), - ].join("\n"), - ); + html = injectPreview(html, { title, description: desc, image: img, video: vid, url }); } return { diff --git a/system/netlify/functions/whistlegraph-thumbnail.mjs b/system/netlify/functions/whistlegraph-thumbnail.mjs new file mode 100644 index 000000000..6c52e8929 --- /dev/null +++ b/system/netlify/functions/whistlegraph-thumbnail.mjs @@ -0,0 +1,81 @@ +// Recover a current TikTok poster for public archive records whose durable +// CDN image is missing. The redirect is deliberately short-lived because +// TikTok's signed image URLs expire; callers keep this stable local URL. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const POSTS_PATH = fileURLToPath(new URL("../../public/whistlegraph.org/posts.json", import.meta.url)); +const POST_ASSETS = "https://assets.aesthetic.computer/whistlegraph/index/posts"; +const CACHE_TTL = 60 * 60 * 1000; +const cache = new Map(); +let postCache = null; + +function loadPosts() { + if (!postCache) { + const data = JSON.parse(readFileSync(POSTS_PATH, "utf8")); + postCache = new Map((data.posts || []).map((post) => [String(post.id), post])); + } + return postCache; +} + +function redirect(location, maxAge = 3600) { + return { + statusCode: 302, + headers: { + Location: location, + "Cache-Control": `public, max-age=${maxAge}`, + "Access-Control-Allow-Origin": "*", + }, + body: "", + }; +} + +const error = (statusCode, message) => ({ + statusCode, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, + body: JSON.stringify({ message }), +}); + +export function createHandler({ fetchFn = fetch, loadPostsFn = loadPosts, nowFn = Date.now } = {}) { + return async (event) => { + if (event.httpMethod !== "GET" && event.httpMethod !== "HEAD") return error(405, "Method Not Allowed."); + const id = String(event.queryStringParameters?.id || ""); + if (!/^\d{10,24}$/.test(id)) return error(400, "A numeric archive post ID is required."); + const post = loadPostsFn().get(id); + if (!post || post.platform !== "tiktok" || !post.url) return error(404, "Video thumbnail not found."); + if (post.thumb) return redirect(post.thumb, 86400); + + const cached = cache.get(id); + if (cached && cached.expiresAt > nowFn()) return redirect(cached.url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6000); + try { + const canonical = `${POST_ASSETS}/${id}.jpg`; + const canonicalResponse = await fetchFn(canonical, { method: "HEAD", signal: controller.signal }); + if (canonicalResponse.ok) { + cache.set(id, { url: canonical, expiresAt: nowFn() + CACHE_TTL }); + return redirect(canonical, 86400); + } + const response = await fetchFn(`https://www.tiktok.com/oembed?url=${encodeURIComponent(post.url)}`, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`TikTok oEmbed returned ${response.status}`); + const data = await response.json(); + const url = new URL(data.thumbnail_url); + if (url.protocol !== "https:" || !/(^|\.)tiktokcdn(?:-us)?\.com$/i.test(url.hostname)) { + throw new Error("TikTok returned an unexpected thumbnail host"); + } + cache.set(id, { url: url.href, expiresAt: nowFn() + CACHE_TTL }); + return redirect(url.href); + } catch (cause) { + console.warn("Whistlegraph thumbnail recovery failed:", id, cause?.message || cause); + return error(404, "Video thumbnail is unavailable."); + } finally { + clearTimeout(timer); + } + }; +} + +export const handler = createHandler(); diff --git a/system/public/whistlegraph.org/index.html b/system/public/whistlegraph.org/index.html index a2e754a1e..19e70cafc 100644 --- a/system/public/whistlegraph.org/index.html +++ b/system/public/whistlegraph.org/index.html @@ -199,6 +199,7 @@ #dPosts .arow .acol{flex:1 1 auto; min-width:0} #dPosts .arow audio{width:100%; height:34px; display:block} #dPosts .arow .pmeta{margin-top:5px} + #dPosts .audio-poster{width:300px; max-width:64vw; aspect-ratio:9/16; object-fit:cover; border:1px solid var(--ink); background:#000; display:block; margin:8px 0} .post .pmeta{margin-top:5px; line-height:1.45; color:var(--dim)} .post .pdate{color:var(--ink)} .post .plink{color:var(--margin); text-decoration:none; white-space:nowrap} @@ -433,7 +434,10 @@ let POSTS_BY_CODE={}; // confirmed work code → [contributing posts]. let POSTS_BY_ID={}; // stable archive post id → post. let LIVE_CURATION={works:{},posts:{}}; // Auth0-admin edits, stored durably by lith. const KIND_LABEL={performance:"Performance",talk:"Talk",other:"Other"}; -const postThumb=p=>p?.thumb||(p?.media!=="audio"&&p?.id?`${IDX}/posts/${p.id}.jpg`:""); +const postThumb=p=>p?.thumb||(p?.platform==="tiktok"&&p?.id?`${IDX}/posts/${p.id}.jpg`:""); +const thumbPost=p=>String(p?.thumbPost||p?.id||""); +const videoPoster=p=>p?.platform==="tiktok"&&p?.id?`/api/whistlegraph-thumbnail?id=${encodeURIComponent(p.id)}`:postThumb(p); +document.addEventListener("error",event=>{const img=event.target;if(img?.tagName!=="IMG")return;const id=img.dataset.thumbPost;if(!id||img.dataset.thumbRecovered)return;img.dataset.thumbRecovered="1";img.src=`/api/whistlegraph-thumbnail?id=${encodeURIComponent(id)}`},true); const esc=s=>String(s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); // Generated files remain the reproducible baseline. Small live patches from @@ -466,7 +470,7 @@ function renderPosts(code){ // loads only small thumbnails, lazily — nothing heavier until you press play. const curated=!!(BY[code]&&BY[code].slug); // curated works front a canonical performance, not posts[0] const vcard=(p,i)=>`
`+ - `
▶
`+ + `
▶
`+ `
${ptag(p)}${meta(p)}${p.desc?`
${esc(p.desc)}
`:""}
`; const arow=p=>`
♪
${meta(p)}
`; let html=`

${posts.length} post${posts.length>1?"s":""}

`; @@ -492,7 +496,7 @@ function renderPosts(code){ const p=vids[+row.dataset.i]; if(!p) return; list.querySelector(".post.active")?.classList.remove("active"); row.classList.add("active"); - if(postThumb(p)) D.video.poster=postThumb(p); + if(postThumb(p)) D.video.poster=videoPoster(p); D.video.src=p.src; D.video.play?.().catch(()=>{}); D.video.scrollIntoView({block:"center", behavior:"smooth"}); @@ -523,7 +527,7 @@ const postTitle=p=>{ }; const postRowHTML=p=>{ const thumb=postThumb(p) - ? `` + ? `` : ``; const related=(p.works||[]).map(code=>`[${code}] ${esc(BY[code]?.title||code)}`).join(" · "); const postPath=`/post/${encodeURIComponent(p.id)}`; @@ -776,7 +780,7 @@ function showDetail(code, keepScroll){ D.video.classList.toggle("wide",!!w.wide); // the Longest film is landscape const aset=w.asset||w.code; // renamed codes keep their original CDN asset key const hero=posts.find(post=>post.id===w.featuredPost)||posts[0]; // editorial choice, then most-seen - D.video.poster=postThumb(hero)||(w.noGlyph?"":`${IDX}/${aset}.jpg`); + D.video.poster=hero?videoPoster(hero):(w.noGlyph?"":`${IDX}/${aset}.jpg`); D.video.src=hero?hero.src:`${IDX}/${aset}.mp4`; if(w.film){ // the Rhizome film, not a TikTok take — its own provenance + glyph D.archiveNote.innerHTML=`Code ${w.code}   —   ${fmtViews(w.views)} views  ·  the ~22-minute Rhizome commission, chalk on a 4×6 blackboard.`; @@ -816,11 +820,11 @@ function showPost(id, keepScroll){ if(p.media==="audio"){ D.video.pause(); D.video.removeAttribute("src"); D.video.style.display="none"; }else{ - D.video.style.display=""; D.video.poster=p.thumb||""; D.video.src=p.src; + D.video.style.display=""; D.video.poster=videoPoster(p); D.video.src=p.src; } const workLinks=related.map(w=>`[${w.code}] ${esc(w.title)}`).join(" · "); const plots=(p.plots||[]).map(plot=>`Plot: ${esc(plot)}`).join(""); - D.posts.innerHTML=`

Relationships

`+ + D.posts.innerHTML=(p.media==="audio"?`Poster frame for this archive post

The archive currently preserves this post's sound and poster, but not its original moving picture.

`:"")+`

Relationships

`+ `

${workLinks||'No confirmed work relationship yet.'}

`+ (plots?`

${plots}

`:"")+ (p.desc?`

${esc(p.desc)}

`:"")+ -- 2.51.2