From 7f77dfe3f11edddbc2910329c1ffd55320bb5056 Mon Sep 17 00:00:00 2001 From: mcinquin Date: Mon, 14 Sep 2026 15:32:28 +0200 Subject: [PATCH] fix(sharp): remove sharp - Incompatibility with Intel Atom CPU --- Dockerfile | 14 ++------ README.md | 4 +-- docs/csv-import-export.md | 4 +-- next.config.ts | 10 +----- package-lock.json | 8 +++-- package.json | 7 ++-- scripts/generate-pwa-icons.mjs | 16 +++++++-- src/lib/card-photo-constants.ts | 35 ++++++++++++++++--- src/lib/card-photo-storage.test.ts | 10 +++--- src/lib/card-photo-storage.ts | 54 ++++++++++++++++++++---------- 10 files changed, 102 insertions(+), 60 deletions(-) diff --git a/Dockerfile b/Dockerfile index e9f4079..c96144e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,15 +8,11 @@ RUN --mount=type=cache,target=/var/cache/apk \ apk add --update-cache --cache-dir /var/cache/apk python3 make g++ COPY package.json package-lock.json ./ -# sharp prebuilds need x86-64-v2 (SSE4.2+). Hosts like Intel Atom N2800 SIGILL -# in libvips. Keep @img/sharp-wasm32 (declared dep) and strip native @img binaries -# so sharp cannot load them. Do NOT use `npm install --cpu=wasm32` (prunes lightningcss). +# Strip sharp if pulled in as an optional transitive of Next.js — native/WASM +# sharp crashes on CPUs without x86-64-v2 / Wasm SIMD (e.g. Intel Atom N2800). RUN --mount=type=cache,target=/root/.npm \ HUSKY=0 npm ci --prefer-offline --no-audit \ - && find node_modules/@img -mindepth 1 -maxdepth 1 -type d \( \ - -name 'sharp-linux*' -o -name 'sharp-libvips-*' \ - \) -exec rm -rf {} + \ - && test -f node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.wasm + && rm -rf node_modules/sharp node_modules/@img # ── Étape 2 : build ─────────────────────────────────────────────────────────── FROM deps AS builder @@ -46,12 +42,8 @@ RUN addgroup --system --gid 1111 hobbyhoops \ COPY --from=builder /app/public ./public COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/.next/standalone ./ COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/.next/static ./.next/static -# Standalone tracing may omit dynamically loaded .wasm; force-copy sharp WASM runtime. -COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/node_modules/@img/sharp-wasm32 ./node_modules/@img/sharp-wasm32 -COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/node_modules/@emnapi ./node_modules/@emnapi RUN mkdir -p /app/data && chown hobbyhoops:hobbyhoops /app/data -RUN test -f /app/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.wasm COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY --chmod=755 scripts/docker-ensure-db.mjs /app/scripts/docker-ensure-db.mjs diff --git a/README.md b/README.md index 1eac5ab..cd846e4 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm run clean # removes .next, TypeScript caches, etc. Everything is stored in **`data/`** (gitignored), mounted as `/app/data` in Docker: - **`data/hobbyhoops.db`** — SQLite (collection, references, accounts, sessions, rate limiting) via `better-sqlite3` -- **`data/card-photos/`** — card front/back images (WebP), uploaded from Admin / receive flow +- **`data/card-photos/`** — card front/back images (JPEG / PNG / GIF / WebP as uploaded), from Admin / receive flow This file and its WAL journals are **local** and must **never** be committed. Card photos live next to the database on the same volume — back up `data/` as a whole if you want images included. @@ -115,7 +115,7 @@ This complements the SQLite backup (`hobbyhoops.db`): CSV is ideal for spreadshe The image starts with an **empty collection**: only a writable `data/` directory is required (the SQLite database is created there automatically). The container runs as the **`hobbyhoops`** system user (UID/GID **1111**). Mount `data/` at `/app/data` and make it writable by that user. -Card-photo processing uses **sharp via WebAssembly** in the Docker image (not the native libvips prebuild). Native sharp/libvips binaries require **x86-64-v2** (SSE4.2+) and crash with `SIGILL` / exit `132` on older CPUs such as Intel Atom N2800. +Card photos are stored **as uploaded** (JPEG / PNG / GIF / WebP) with no native image library. The app deliberately avoids `sharp` so it runs on older CPUs such as Intel Atom N2800 (no x86-64-v2 / Wasm SIMD). The application listens on **`127.0.0.1:3000`** (not exposed on all interfaces). In production, place a reverse proxy (e.g. Apache) in front of the host and proxy to that address. diff --git a/docs/csv-import-export.md b/docs/csv-import-export.md index 0e92675..371992f 100644 --- a/docs/csv-import-export.md +++ b/docs/csv-import-export.md @@ -84,8 +84,8 @@ id,player,team,year,brand,set,variation,autograph,memorabilia,rookie,tradable,wn | `opening_date` | Non | Date d’ajout à la collection | `15/03/2024` | | `protection` | Non | Protection (toploader, etc.) | `Penny sleeve` | | `storage` | Non | Lieu de rangement | `Boîte A` | -| `photo_front` | Non | Recto : chemin local `/api/card-photos/…` ou URL HTTPS / data URI | `/api/card-photos/card-0001/front.webp` | -| `photo_back` | Non | Verso : idem | `/api/card-photos/card-0001/back.webp` | +| `photo_front` | Non | Recto : chemin local `/api/card-photos/…` ou URL HTTPS / data URI | `/api/card-photos/card-0001/front.jpg` | +| `photo_back` | Non | Verso : idem | `/api/card-photos/card-0001/back.png` | ### Alias d’en-tête (français) diff --git a/next.config.ts b/next.config.ts index af7cdb3..386401f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,15 +7,7 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { output: "standalone", poweredByHeader: false, - serverExternalPackages: ["better-sqlite3", "sharp"], - // Standalone tracing often omits sharp's dynamically loaded .wasm / natives. - outputFileTracingIncludes: { - "/**": [ - "./node_modules/sharp/**/*", - "./node_modules/@img/sharp-wasm32/**/*", - "./node_modules/@emnapi/**/*", - ], - }, + serverExternalPackages: ["better-sqlite3"], turbopack: { root: projectRoot, }, diff --git a/package-lock.json b/package-lock.json index dd99878..3ada6ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.0.2", "dependencies": { "@base-ui/react": "^1.8.0", - "@img/sharp-wasm32": "0.35.4", "@tanstack/react-table": "^9.1.2", "better-sqlite3": "^13.0.3", "class-variance-authority": "^0.7.1", @@ -22,7 +21,6 @@ "react-dom": "19.2.8", "recharts": "^3.10.1", "server-only": "^0.0.1", - "sharp": "0.35.4", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.5.4" @@ -922,6 +920,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", + "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -1153,6 +1152,7 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -1609,6 +1609,7 @@ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { "@emnapi/runtime": "^1.11.3" }, @@ -5644,6 +5645,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -13485,6 +13487,7 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", + "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -13534,6 +13537,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 1631b7e..13118fb 100644 --- a/package.json +++ b/package.json @@ -33,20 +33,18 @@ }, "dependencies": { "@base-ui/react": "^1.8.0", - "@img/sharp-wasm32": "0.35.4", "@tanstack/react-table": "^9.1.2", "better-sqlite3": "^13.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "csv-parse": "^7.0.2", - "next": "16.3.4", "lucide-react": "^1.42.0", + "next": "16.3.4", "pino": "^10.3.1", "react": "19.2.8", "react-dom": "19.2.8", "recharts": "^3.10.1", "server-only": "^0.0.1", - "sharp": "0.35.4", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "zod": "^4.5.4" @@ -74,7 +72,6 @@ }, "overrides": { "nanoid": "^3.3.18", - "postcss": "8.5.23", - "sharp": "0.35.4" + "postcss": "8.5.23" } } diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs index 7b59476..9494e06 100644 --- a/scripts/generate-pwa-icons.mjs +++ b/scripts/generate-pwa-icons.mjs @@ -1,6 +1,7 @@ /** * Generates raster PNG icons required for PWA installation on Android and iOS. - * Uses sharp (bundled with Next.js) to rasterise src/app/icon.svg. + * Requires a one-off `sharp` install on a modern CPU (not shipped in production): + * npm install --no-save sharp && npm run generate:icons * * Outputs: * public/icons/icon-192x192.png – standard manifest icon @@ -9,13 +10,24 @@ * public/icons/apple-touch-icon.png – 180×180 for iOS */ -import sharp from "sharp"; import { mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { getLogger } from "./lib/logger.mjs"; const log = getLogger("generate-pwa-icons"); + +let sharp; +try { + sharp = (await import("sharp")).default; +} catch { + log.error({ + msg: "sharp is not installed — required only for this maintainer script", + hint: "npm install --no-save sharp && npm run generate:icons", + }); + process.exit(1); +} + const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const iconSvg = join(root, "src/app/icon.svg"); const outputDir = join(root, "public/icons"); diff --git a/src/lib/card-photo-constants.ts b/src/lib/card-photo-constants.ts index 7d05627..36b3f13 100644 --- a/src/lib/card-photo-constants.ts +++ b/src/lib/card-photo-constants.ts @@ -4,7 +4,18 @@ export type CardPhotoSide = (typeof CARD_PHOTO_SIDES)[number]; /** Limite par fichier (photos téléphone / scanner). */ export const CARD_PHOTO_MAX_BYTES = 15 * 1024 * 1024; -export const CARD_PHOTO_OUTPUT_EXT = ".webp"; +/** Formats stockés tels quels (pas de ré-encodage — compatible CPU sans SSE4.2 / Wasm SIMD). */ +export const STOREABLE_CARD_PHOTO_MIME = { + "image/jpeg": { ext: ".jpg", contentType: "image/jpeg" }, + "image/png": { ext: ".png", contentType: "image/png" }, + "image/gif": { ext: ".gif", contentType: "image/gif" }, + "image/webp": { ext: ".webp", contentType: "image/webp" }, +} as const; + +export type StoreableCardPhotoMime = keyof typeof STOREABLE_CARD_PHOTO_MIME; + +const LOCAL_PHOTO_PATH_RE = + /^\/api\/card-photos\/[^/]+\/(front|back)\.(webp|jpe?g|png|gif)$/i; /** MIME acceptés à l’upload (formats image courants ; SVG exclu). */ export const ACCEPTED_IMAGE_MIME_TYPES = [ @@ -26,11 +37,16 @@ export function isCardPhotoSide(value: string): value is CardPhotoSide { } export function isLocalCardPhotoUrl(value: string): boolean { - return /^\/api\/card-photos\/[^/]+\/(front|back)\.webp$/i.test(value); + return LOCAL_PHOTO_PATH_RE.test(value); } -export function publicCardPhotoUrl(cardId: string, side: CardPhotoSide): string { - return `/api/card-photos/${encodeURIComponent(cardId)}/${side}${CARD_PHOTO_OUTPUT_EXT}`; +export function publicCardPhotoUrl( + cardId: string, + side: CardPhotoSide, + ext: string +): string { + const normalized = ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`; + return `/api/card-photos/${encodeURIComponent(cardId)}/${side}${normalized}`; } export function cardPhotoFileInputAccept(): string { @@ -51,3 +67,14 @@ export function cardPhotoFileInputAccept(): string { ".ico", ].join(","); } + +export function contentTypeForCardPhotoFilename(filename: string): string | null { + const ext = filename.includes(".") + ? `.${filename.split(".").pop()!.toLowerCase()}` + : ""; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".png") return "image/png"; + if (ext === ".gif") return "image/gif"; + if (ext === ".webp") return "image/webp"; + return null; +} diff --git a/src/lib/card-photo-storage.test.ts b/src/lib/card-photo-storage.test.ts index 90bdab7..5c42a17 100644 --- a/src/lib/card-photo-storage.test.ts +++ b/src/lib/card-photo-storage.test.ts @@ -41,7 +41,7 @@ describe("card-photo-storage", () => { } }); - it("saves a webp under data/card-photos and reads it back", async () => { + it("saves a png under data/card-photos and reads it back", async () => { const saved = await saveCardPhoto({ cardId: "card-0001", side: "front", @@ -51,15 +51,15 @@ describe("card-photo-storage", () => { expect(saved.ok).toBe(true); if (!saved.ok) return; - expect(saved.url).toBe("/api/card-photos/card-0001/front.webp"); + expect(saved.url).toBe("/api/card-photos/card-0001/front.png"); expect( - fs.existsSync(path.join(getCardPhotosRoot(), "card-0001", "front.webp")) + fs.existsSync(path.join(getCardPhotosRoot(), "card-0001", "front.png")) ).toBe(true); - const read = readCardPhotoFile("card-0001", "front.webp"); + const read = readCardPhotoFile("card-0001", "front.png"); expect(read.ok).toBe(true); if (!read.ok) return; - expect(read.contentType).toBe("image/webp"); + expect(read.contentType).toBe("image/png"); expect(read.buffer.length).toBeGreaterThan(0); }); diff --git a/src/lib/card-photo-storage.ts b/src/lib/card-photo-storage.ts index 2c99a13..59effa6 100644 --- a/src/lib/card-photo-storage.ts +++ b/src/lib/card-photo-storage.ts @@ -3,14 +3,15 @@ import "server-only"; import { createHash } from "crypto"; import fs from "fs"; import path from "path"; -import sharp from "sharp"; import { ACCEPTED_IMAGE_MIME_TYPES, CARD_PHOTO_MAX_BYTES, - CARD_PHOTO_OUTPUT_EXT, + STOREABLE_CARD_PHOTO_MIME, + contentTypeForCardPhotoFilename, isLocalCardPhotoUrl, publicCardPhotoUrl, type CardPhotoSide, + type StoreableCardPhotoMime, } from "@/lib/card-photo-constants"; export { @@ -24,9 +25,8 @@ export { type CardPhotoSide, } from "@/lib/card-photo-constants"; -const OUTPUT_MIME = "image/webp"; const CARD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -const SIDE_FILE_RE = /^(front|back)\.webp$/; +const SIDE_FILE_RE = /^(front|back)\.(webp|jpe?g|png|gif)$/i; function getDataRoot(): string { const configured = process.env.HOBBYHOOPS_DB_PATH?.trim(); @@ -145,6 +145,19 @@ function normalizeDeclaredMime(mime: string): string { return base; } +function resolveStoreableMime( + sniffed: string | null, + declared: string | null +): StoreableCardPhotoMime | null { + if (sniffed && sniffed in STOREABLE_CARD_PHOTO_MIME) { + return sniffed as StoreableCardPhotoMime; + } + if (declared && declared in STOREABLE_CARD_PHOTO_MIME) { + return declared as StoreableCardPhotoMime; + } + return null; +} + export type SaveCardPhotoResult = | { ok: true; url: string; bytes: number } | { @@ -158,6 +171,11 @@ export type SaveCardPhotoResult = | "invalid_card_id"; }; +/** + * Persists a card photo without re-encoding. + * JPEG / PNG / GIF / WebP are stored as-is so the app runs on CPUs that cannot + * load sharp (no x86-64-v2 / no Wasm SIMD), e.g. Intel Atom N2800. + */ export async function saveCardPhoto(options: { cardId: string; side: CardPhotoSide; @@ -201,16 +219,13 @@ export async function saveCardPhoto(options: { return { ok: false, code: "unsupported_type" }; } - let webp: Buffer; - try { - webp = await sharp(buffer, { failOn: "none" }) - .rotate() - .webp({ quality: 85, effort: 4 }) - .toBuffer(); - } catch { + const storeable = resolveStoreableMime(sniffed, declared); + if (!storeable) { + // Formats that would need a converter (HEIC, AVIF, TIFF, …) — no sharp on Atom. return { ok: false, code: "convert_failed" }; } + const { ext } = STOREABLE_CARD_PHOTO_MIME[storeable]; const dir = cardPhotoDir(cardId); fs.mkdirSync(dir, { recursive: true }); @@ -220,19 +235,19 @@ export async function saveCardPhoto(options: { } } - const filename = `${side}${CARD_PHOTO_OUTPUT_EXT}`; + const filename = `${side}${ext}`; const target = path.join(dir, filename); const tmp = path.join( dir, - `.${side}-${createHash("sha1").update(webp).digest("hex").slice(0, 8)}.tmp` + `.${side}-${createHash("sha1").update(buffer).digest("hex").slice(0, 8)}.tmp` ); - fs.writeFileSync(tmp, webp); + fs.writeFileSync(tmp, buffer); fs.renameSync(tmp, target); return { ok: true, - url: publicCardPhotoUrl(cardId, side), - bytes: webp.length, + url: publicCardPhotoUrl(cardId, side, ext), + bytes: buffer.length, }; } @@ -248,6 +263,9 @@ export function readCardPhotoFile( return { ok: false }; } + const contentType = contentTypeForCardPhotoFilename(filename); + if (!contentType) return { ok: false }; + const dir = path.join(getCardPhotosRoot(), cardId); const resolved = path.resolve(dir, filename); if (!resolved.startsWith(path.resolve(dir) + path.sep)) { @@ -263,7 +281,7 @@ export function readCardPhotoFile( return { ok: true, buffer: fs.readFileSync(resolved), - contentType: OUTPUT_MIME, + contentType, mtimeMs: stat.mtimeMs, }; } @@ -296,7 +314,7 @@ export function deleteAllCardPhotos(cardId: string): void { export function deleteLocalPhotoIfStored(url: string | null | undefined): void { if (!url || !isLocalCardPhotoUrl(url)) return; const match = url.match( - /^\/api\/card-photos\/([^/]+)\/(front|back)\.webp$/i + /^\/api\/card-photos\/([^/]+)\/(front|back)\.(webp|jpe?g|png|gif)$/i ); if (!match) return; const cardId = decodeURIComponent(match[1]!);