Files
gerbeur/api/db/migrations/0011_split_favicon_thumbnail.ts
khannurien 1cb904d2cf
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
v3: generated placeholder thumbnails for pages with no preview image, and a fix for hotlink-protected ones
Thumbnails were failing in two different ways that both ended as an empty box.

Cloudflare hotlink protection answers a cross-site Referer with 403, so images
we had extracted correctly (dles.aukspot.com's og:image among them) never
rendered — every onError handler set display:none and swallowed it. The new
Thumbnail component loads with referrerPolicy="no-referrer", retries once
through /api/proxy-image for hosts that reject an empty referrer too, and only
then falls back to a placeholder.

Separately, the extraction cascade ended at the page's icon and then a guessed
/favicon.ico, so thumbnailUrl was almost never empty — just a 16x16 icon
cover-cropped into a 128x72 box. It now stops at real artwork, with faviconUrl
and accentColor (theme-color / msapplication-TileColor / mask-icon) as their own
fields. An absent thumbnailUrl finally means "no artwork", which is what makes
the placeholder possible: the site's own color mixed into the theme surface,
with its favicon centered on it, or its initial. Contrast holds for any
third-party color by construction rather than by luminance math, so nyt only
has to set --thumb-tint-strength to 0% to stay monochrome and geocities only
has to raise it. Missing accents fall back to a stable hostname-derived hue, so
rows saved before this get a tint with no backfill.

Migration 0011 reclassifies favicon-shaped thumbnailUrls on existing dumps.
The journal mosaic keeps its pull-quote and text fallbacks — the placeholder
appears there only to repair a broken image.

Also fixed: refresh silently overwrote good metadata with a failure stub, the
refresh button swallowed every error, refresh never broadcast the update,
extractBestIcon ranked SVG icons below 16x16 PNGs, and shared links with no
artwork carried no og:image at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00

75 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DatabaseSync } from "node:sqlite";
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
// `faviconUrl` field.
//
// The extraction cascade used to end at the page's icon and then at a guessed
// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
// box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
// means "no artwork", which is what lets the frontend draw a placeholder.
// This migration gives rows written before that change the same meaning.
//
// Purely local — it classifies the already-stored URL and makes no network
// calls, so `accentColor` is deliberately not backfilled: the frontend derives
// a stable hue from the hostname whenever one is missing, and
// `refreshDumpMetadata` fetches the real color on demand.
//
// Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
// database built from schema.sql is a no-op.
/**
* Whether a stored thumbnail URL is really a site icon.
*
* Deliberately loose. A false positive (a genuine cover image living under
* `/assets/icons/`) renders contained on a tinted field instead of
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
* extra rules.
*/
function isIconUrl(raw: string): boolean {
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
return false;
}
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
/\.(ico|svg)$/i.test(pathname);
}
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
).all() as { id: string; rich_content: string }[];
const update = db.prepare(
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
);
let patched = 0;
for (const row of rows) {
let rich: { thumbnailUrl?: string; faviconUrl?: string };
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
}
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
if (!isIconUrl(rich.thumbnailUrl)) continue;
const { thumbnailUrl: _dropped, ...rest } = rich;
update.run(
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
row.id,
);
patched++;
}
if (patched > 0) {
console.log(
`[migrate] 0011: reclassified ${patched} favicon thumbnail(s)`,
);
}
}