Fix: a manually-set cover_image URL was never cached locally

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.
This commit is contained in:
2026-08-31 08:50:16 +03:00
parent 8da5d372d5
commit 22302d8db9
2 changed files with 97 additions and 21 deletions
+65
View File
@@ -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<string | undefined> {
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[];
+18 -7
View File
@@ -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,21 +281,27 @@ 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<string, unknown>,
): Promise<Record<string, unknown>> {
if (entry.cover_image) return entry;
const slug = entry.slug as string;
const coverImage = entry.cover_image as string | undefined;
const patch: Record<string, unknown> = {};
try {
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;
@@ -308,6 +318,7 @@ async function enrichBookshelfEntry(
patch.authors = meta.authors;
}
}
}
} catch {
// Network/API failures shouldn't block saving the entry itself.
return entry;