From 22302d8db954a4b6c9b8e71d1dafffa1daa6c121 Mon Sep 17 00:00:00 2001 From: andwati Date: Mon, 31 Aug 2026 08:50:16 +0300 Subject: [PATCH] Fix: a manually-set cover_image URL was never cached locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enrichment guard was `if (entry.cover_image) return entry;` — meaning once *any* cover_image was set, including a raw external URL you pasted into Strapi by hand, it was treated as "already done" and never downloaded. The site's coverExists() check only looks for local /covers/... files, so a remote URL silently never rendered. - content-sync.ts: enrichBookshelfEntry now branches on whether cover_image is an http(s) URL (cache it via the new cacheRemoteImage) vs. unset (existing ISBN/DOI lookup) vs. already a local path (left alone). - bookshelf-covers.ts: cacheRemoteImage() downloads and caches any image URL. Also fixes a real bug caught while testing this against the actual O'Reilly cover URL from the reported entry: their CDN labels WebP responses as Content-Type: image/jpeg, so trusting the header would've cached it with the wrong extension. Now sniffs the real file signature (RIFF/WEBP, PNG, JPEG, GIF magic bytes) instead of trusting either the header or the URL. Verified the download+sniff logic directly against the real cover URL from the reported entry (Designing Data-Intensive Applications) — correctly detects and saves as .webp. Didn't touch the running dev Strapi/DB while testing. --- apps/cms/src/utils/bookshelf-covers.ts | 65 ++++++++++++++++++++++++++ apps/cms/src/utils/content-sync.ts | 53 ++++++++++++--------- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/apps/cms/src/utils/bookshelf-covers.ts b/apps/cms/src/utils/bookshelf-covers.ts index fd967aa..ce68f6c 100644 --- a/apps/cms/src/utils/bookshelf-covers.ts +++ b/apps/cms/src/utils/bookshelf-covers.ts @@ -32,6 +32,71 @@ export async function fetchBookCover( return `/covers/${slug}.jpg`; } +/** + * Sniffs the actual file signature rather than trusting the Content-Type + * header — some CDNs (O'Reilly's included, observed while building this) + * mislabel WebP responses as image/jpeg, which would otherwise get a wrong + * extension and a mismatched Content-Type when we later serve it ourselves. + */ +function extensionFromBytes(buf: Buffer): string { + if ( + buf.length >= 12 && + buf.subarray(0, 4).toString("ascii") === "RIFF" && + buf.subarray(8, 12).toString("ascii") === "WEBP" + ) { + return "webp"; + } + if ( + buf.length >= 8 && + buf + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return "png"; + } + if ( + buf.length >= 3 && + buf[0] === 0xff && + buf[1] === 0xd8 && + buf[2] === 0xff + ) { + return "jpg"; + } + if ( + buf.length >= 6 && + (buf.subarray(0, 6).toString("ascii") === "GIF87a" || + buf.subarray(0, 6).toString("ascii") === "GIF89a") + ) { + return "gif"; + } + return "jpg"; +} + +/** + * Downloads whatever image URL is at `url` (e.g. a cover_image the user + * pasted in by hand) and caches it locally, so the site never depends on + * that URL staying up. Used for any http(s) cover_image, not just ISBN + * lookups. + */ +export async function cacheRemoteImage( + url: string, + slug: string, +): Promise { + try { + const res = await fetch(url); + if (!res.ok) return undefined; + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.byteLength < 200) return undefined; + const ext = extensionFromBytes(buf); + const file = join(COVERS_DIR, `${slug}.${ext}`); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, buf); + return `/covers/${slug}.${ext}`; + } catch { + return undefined; + } +} + interface PaperMetadata { title?: string; authors?: string[]; diff --git a/apps/cms/src/utils/content-sync.ts b/apps/cms/src/utils/content-sync.ts index 0a374e2..f29afa6 100644 --- a/apps/cms/src/utils/content-sync.ts +++ b/apps/cms/src/utils/content-sync.ts @@ -1,5 +1,9 @@ import { join } from "node:path"; -import { fetchBookCover, fetchPaperMetadata } from "./bookshelf-covers"; +import { + cacheRemoteImage, + fetchBookCover, + fetchPaperMetadata, +} from "./bookshelf-covers"; import { deleteEntry, readAllEntries, @@ -277,35 +281,42 @@ interface DocumentsClient { } /** - * Backfills a bookshelf entry's cover (books, via Open Library by ISBN) or - * missing title/authors (papers, via Semantic Scholar by DOI/arXiv id) the - * first time it's created/updated with an identifier but no cached cover. - * Runs once per entry: `cover_image` already set is treated as "done". + * Backfills a bookshelf entry's cover — either by caching whatever URL is + * already in `cover_image` (e.g. one pasted in by hand, or an editor's own + * hosted cover) locally, or by looking one up (books via Open Library by + * ISBN) when there's no cover_image at all yet — and, for papers, missing + * title/authors via Semantic Scholar by DOI/arXiv id. Runs once per entry: + * a `cover_image` already pointing at a local /covers/... path is treated + * as "done" and left alone. */ async function enrichBookshelfEntry( documents: (uid: string) => DocumentsClient, entry: Record, ): Promise> { - if (entry.cover_image) return entry; - const slug = entry.slug as string; + const coverImage = entry.cover_image as string | undefined; const patch: Record = {}; try { - if (entry.kind === "book" && entry.isbn) { - const cover = await fetchBookCover(entry.isbn as string, slug); - if (cover) patch.cover_image = cover; - } else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) { - const meta = await fetchPaperMetadata({ - doi: entry.doi as string | undefined, - arxivId: entry.arxiv_id as string | undefined, - }); - if (meta?.title && !entry.title) patch.title = meta.title; - if ( - meta?.authors?.length && - !(entry.authors as string[] | undefined)?.length - ) { - patch.authors = meta.authors; + if (coverImage && /^https?:\/\//i.test(coverImage)) { + const cached = await cacheRemoteImage(coverImage, slug); + if (cached) patch.cover_image = cached; + } else if (!coverImage) { + if (entry.kind === "book" && entry.isbn) { + const cover = await fetchBookCover(entry.isbn as string, slug); + if (cover) patch.cover_image = cover; + } else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) { + const meta = await fetchPaperMetadata({ + doi: entry.doi as string | undefined, + arxivId: entry.arxiv_id as string | undefined, + }); + if (meta?.title && !entry.title) patch.title = meta.title; + if ( + meta?.authors?.length && + !(entry.authors as string[] | undefined)?.length + ) { + patch.authors = meta.authors; + } } } } catch {