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

57
src/utils/proxyImage.ts Normal file
View File

@@ -0,0 +1,57 @@
import { API_URL } from "../config/api.ts";
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
function apiOrigin(): string {
try {
return new URL(API_URL, location.href).origin;
} catch {
return location.origin;
}
}
/**
* Whether `url` points at a third party, i.e. whether proxying it would
* actually change anything. Our own files and thumbnails are served with no
* `Referer` checks, so retrying them through the proxy is pure waste.
*/
export function canProxyImage(url: string): boolean {
try {
const u = new URL(url, location.href);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
return u.origin !== apiOrigin() && u.origin !== location.origin;
} catch {
return false;
}
}
interface ProxyImageOptions {
/**
* Proxy even when the URL is already HTTPS. Used as a retry after a direct
* load fails — typically hotlink protection rejecting our cross-site
* `Referer`, which the server-side fetch doesn't send.
*/
force?: boolean;
}
/**
* Route an external image through `GET /api/proxy-image`.
*
* By default only HTTP URLs are proxied, so they don't trigger mixed-content
* blocks when the frontend is served over HTTPS. `force` opts an HTTPS URL in
* as well.
*/
export function proxyImageUrl(url: string, opts: ProxyImageOptions = {}): string {
let u: URL;
try {
u = new URL(url);
} catch {
return url; // relative or same-origin path — nothing to proxy
}
if (u.protocol !== "http:" && u.protocol !== "https:") return url;
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
if (isLocal) return url;
if (!opts.force && u.protocol !== "http:") return url;
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
}

View File

@@ -0,0 +1,63 @@
/**
* Color and lettering for generated thumbnail placeholders.
*
* The tint is never painted on its own — it's mixed into the theme's surface
* color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for
* any site color, in every style, in both light and dark.
*/
/**
* `accentColor` comes from a dumped page's `theme-color`, so it's
* attacker-controlled. Custom properties bypass CSS value parsing and get
* substituted verbatim wherever the var is used, so the canonical form is
* re-checked here before it ever reaches a style object.
*/
export function isSafeHex(value: string | undefined): value is string {
return !!value && /^#[0-9a-f]{6}$/.test(value);
}
/** FNV-1a — small, stable, and well spread across the hue circle. */
function hashString(seed: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/** A stable hue for a string, so the same site always gets the same tint. */
export function hueFromString(seed: string): string {
return `hsl(${hashString(seed) % 360} 55% 55%)`;
}
function hostnameOf(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* The page's own brand color when it declared a usable one, otherwise a hue
* derived from its hostname — so every card gets a tint, including rows saved
* before accent extraction existed.
*/
export function tintFor(
{ accentColor, url }: { accentColor?: string; url?: string },
): string {
if (isSafeHex(accentColor)) return accentColor;
return hueFromString(hostnameOf(url) ?? url ?? "");
}
/** The letter drawn when there's no favicon to show. */
export function initialsFor(
siteName: string | undefined,
url: string | undefined,
): string {
const source = siteName?.trim() || hostnameOf(url) || url || "";
const letter = [...source].find((ch) => /\p{L}|\p{N}/u.test(ch));
return (letter ?? "?").toUpperCase();
}