v3: generated placeholder thumbnails for pages with no preview image, and a fix for hotlink-protected ones
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s

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>
This commit is contained in:
khannurien
2026-08-11 19:43:55 +00:00
parent 0e138be6df
commit 1cb904d2cf
21 changed files with 736 additions and 130 deletions

View File

@@ -9,6 +9,7 @@ import { up as up0007PasswordResetTokens } from "./migrations/0007_password_rese
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
interface Migration {
name: string;
@@ -29,6 +30,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply },
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,74 @@
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)`,
);
}
}

View File

@@ -11,6 +11,8 @@ interface OGMeta {
title: string;
description?: string;
imageUrl?: string;
/** True only for real artwork — a square site icon must not claim a wide card. */
imageIsWide?: boolean;
url: string;
}
@@ -23,7 +25,9 @@ function escapeAttr(s: string): string {
}
function buildTags(meta: OGMeta): string {
const card = meta.imageUrl ? "summary_large_image" : "summary";
const card = meta.imageUrl && meta.imageIsWide
? "summary_large_image"
: "summary";
const tags = [
`<title>${escapeAttr(meta.title)}</title>`,
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
@@ -105,10 +109,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
} else if (dump.richContent?.thumbnailUrl) {
imageUrl = dump.richContent.thumbnailUrl;
}
// No artwork: fall back to our own site icon rather than the target's
// favicon, which is typically too small to survive a social card (and may
// be hotlink-protected).
meta = {
title: dump.title,
description: dump.comment,
imageUrl,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
imageIsWide: !!imageUrl,
url: pageUrl,
};
} catch { /* not found or private — serve default */ }
@@ -121,7 +129,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = {
title: user.username,
description: user.description,
imageUrl,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl,
};
} catch { /* not found */ }
@@ -135,7 +143,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = {
title: playlist.title,
description: playlist.description,
imageUrl,
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl,
};
}

View File

@@ -10,7 +10,16 @@ export interface RichContent {
siteName?: string;
title?: string;
description?: string;
/**
* A real preview image (og:image, a large content image, a video still).
* Never a favicon — an absent value means "this page offers no artwork",
* which is what makes the generated placeholder possible.
*/
thumbnailUrl?: string;
/** The page's own icon, used as the placeholder's glyph. */
faviconUrl?: string;
/** The page's declared brand color, normalized to `#rrggbb`. */
accentColor?: string;
videoId?: string;
embedUrl?: string;
}

View File

@@ -13,7 +13,11 @@ import {
dumpRowToApi,
isDumpRow,
} from "../model/db.ts";
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
import {
fetchRichContent,
isValidHttpUrl,
tryFetchRichContent,
} from "./rich-content-service.ts";
import {
broadcastDumpDeleted,
broadcastDumpUpdated,
@@ -596,7 +600,19 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
);
}
const richContent = await fetchRichContent(dump.url);
// A failed fetch yields a stub with no title and no thumbnail. Writing that
// over a dump that already has good metadata would silently destroy it and
// rename the dump to its bare hostname, so bail out instead — the caller
// surfaces the error and nothing is lost.
const { ok, content: richContent } = await tryFetchRichContent(dump.url);
if (!ok) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach the page to refresh its metadata",
);
}
const title = richContent?.title ?? titleFromUrl(dump.url);
const updatedDump: Dump = { ...dump, title, richContent };
@@ -605,6 +621,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
).run(row.title, row.rich_content, row.id);
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump;
}

View File

@@ -5,10 +5,13 @@ import {
extractFirstContentImage,
extractJsonLd,
extractLargeImage,
extractMaskIconColor,
extractMetaName,
extractOgTag,
extractPageTitle,
extractThemeColor,
fetchWithTimeout,
normalizeCssColor,
} from "../rich-content-service.ts";
export const genericProvider: RichContentProvider = {
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
ld.description ??
extractMetaName(html, "description");
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → first content <img> → best icon → /favicon.ico
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> →
// first content <img>. The chain deliberately stops there: a favicon is not
// artwork, and pretending otherwise means every art-less page gets a 16×16
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
// and the frontend draws a generated placeholder instead.
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
extractMetaName(html, "twitter:image") ??
ld.thumbnailUrl ??
extractLargeImage(html, url) ??
extractFirstContentImage(html, url) ??
extractBestIcon(html, url) ??
extractFirstContentImage(html, url);
// Icon and brand color are independent facts about the page, so they're
// collected whether or not there's artwork. `/favicon.ico` is a guess; when
// it 404s the placeholder falls back to the site's initial.
const faviconUrl = extractBestIcon(html, url) ??
`${new URL(url).origin}/favicon.ico`;
const accentColor = normalizeCssColor(
extractThemeColor(html) ??
extractMetaName(html, "msapplication-TileColor") ??
extractMaskIconColor(html),
);
return {
type: "generic",
url,
title,
description,
thumbnailUrl,
faviconUrl,
accentColor,
siteName,
};
},

View File

@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
import { getDump } from "../dump-service.ts";
import { getUserByUsername } from "../user-service.ts";
import { getPlaylistById } from "../playlist-service.ts";
import { PUBLIC_URL } from "../../config.ts";
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
const DUMP_RE = /^\/dumps\/([^/]+)$/;
const USER_RE = /^\/users\/([^/]+)$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
// Internal links carry gerbeur's own branding, so an art-less one still gets a
// recognizable placeholder instead of a generic tint.
const SELF_BRANDING = {
siteName: "gerbeur",
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
accentColor: THEME_COLOR,
} as const;
export const selfProvider: RichContentProvider = {
name: "self",
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
siteName: "gerbeur",
...SELF_BRANDING,
title: dump.title,
description: dump.comment,
thumbnailUrl,
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
siteName: "gerbeur",
...SELF_BRANDING,
title: user.username,
description: user.description,
thumbnailUrl,
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({
type: "generic",
url,
siteName: "gerbeur",
...SELF_BRANDING,
title: playlist.title,
description: playlist.description,
thumbnailUrl,

View File

@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | undefined {
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
}
// ── Brand color helpers ───────────────────────────────────────────────────────
/**
* Find the page's `theme-color`.
*
* Sites routinely ship the tag twice, scoped by `media`, and often list the
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
*/
export function extractThemeColor(html: string): string | undefined {
const tagRe = /<meta\b[^>]*>/gi;
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
let unscoped: string | undefined;
let light: string | undefined;
let any: string | undefined;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
const content = contentRe.exec(tag)?.[2];
if (!content) continue;
const media = mediaRe.exec(tag)?.[2];
if (!media) unscoped ??= content;
else if (/light/i.test(media)) light ??= content;
any ??= content;
}
return unscoped ?? light ?? any;
}
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
if (color) return color;
}
return undefined;
}
// The handful of named colors that actually show up in `theme-color`. The full
// 148-name table isn't worth carrying for the long tail.
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
/**
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
* only ever used as a tint over the app's own surface.
*/
export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
const named = NAMED_COLORS[value];
if (named) return named;
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
const [r, g, b] = [...hex.slice(0, 3)];
return `#${r}${r}${g}${g}${b}${b}`;
}
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
return undefined;
}
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (rgb) {
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
if (parts.length !== 3) return undefined;
const bytes = parts.map((p) => {
const n = parseFloat(p);
if (!Number.isFinite(n)) return NaN;
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
});
if (bytes.some(Number.isNaN)) return undefined;
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
return undefined;
}
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
type JsonLdResult = {
@@ -281,6 +392,10 @@ export function extractLargeImage(
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present.
*
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
* whatever size the placeholder renders them at.
*/
export function extractBestIcon(
html: string,
@@ -302,7 +417,13 @@ export function extractBestIcon(
if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
const sm = sizesStr.match(/(\d+)x(\d+)/i);
const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0;
const isSvg = /\.svg(\?|$)/i.test(href) ||
/\btype=["']image\/svg\+xml["']/i.test(tag);
const area = isSvg
? Number.MAX_SAFE_INTEGER
: sm
? parseInt(sm[1]) * parseInt(sm[2])
: 0;
try {
candidates.push({ href: new URL(href, baseUrl).toString(), area });
} catch {
@@ -431,24 +552,46 @@ export function isValidHttpUrl(raw: string): boolean {
}
}
export async function fetchRichContent(
export interface FetchRichContentResult {
/** False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. Callers that already hold good metadata must
* not overwrite it with a failure stub. */
ok: boolean;
content?: RichContent;
}
/**
* Fetch metadata for `url`, reporting whether the fetch itself succeeded.
*
* On failure it still yields a minimal stub so a *new* dump has something
* displayable, but `ok: false` lets callers with existing metadata keep it.
*/
export async function tryFetchRichContent(
url: string,
): Promise<RichContent | undefined> {
): Promise<FetchRichContentResult> {
try {
const provider = providers.find((p) => p.matches(url))!;
return await provider.fetch(url);
return { ok: true, content: await provider.fetch(url) };
} catch (err) {
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
// Return a minimal stub so the caller always gets something displayable
// (e.g. when the site has a bad TLS cert or the fetch times out).
try {
return {
type: "generic",
url,
siteName: new URL(url).hostname.replace(/^www\./, ""),
ok: false,
content: {
type: "generic",
url,
siteName: new URL(url).hostname.replace(/^www\./, ""),
},
};
} catch {
return undefined;
return { ok: false };
}
}
}
/** Metadata for `url`, or a minimal stub when it can't be fetched. */
export async function fetchRichContent(
url: string,
): Promise<RichContent | undefined> {
return (await tryFetchRichContent(url)).content;
}