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
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:
57
src/utils/proxyImage.ts
Normal file
57
src/utils/proxyImage.ts
Normal 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)}`;
|
||||
}
|
||||
63
src/utils/thumbnailTint.ts
Normal file
63
src/utils/thumbnailTint.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user