From 227e4e3f2a41f5f2c94ed9dcd47d0d7aa508244a Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:14:16 +0100 Subject: [PATCH] fix(runtime): make sharp optional on FreeBSD startup --- README.md | 4 +- package.json | 4 +- .../patch-endpoint-media-sharp-runtime.mjs | 112 +++++++++++++++ scripts/patch-frontend-sharp-runtime.mjs | 133 ++++++++++++++++++ start.example.sh | 2 + 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 scripts/patch-endpoint-media-sharp-runtime.mjs create mode 100644 scripts/patch-frontend-sharp-runtime.mjs diff --git a/README.md b/README.md index 61f63de1..0c87114c 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ - `start.sh` is intentionally ignored by Git (`.gitignore`) so server secrets are not committed. - Use `start.example.sh` as the tracked template and keep real credentials in environment variables (or `.env` on the server). - Startup scripts parse `.env` with the `dotenv` parser (not shell `source`), so values containing spaces are handled safely. -- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-mongo-connection.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`). +- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-mongo-connection.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`). - The media scope patch fixes a known upstream issue where file uploads can fail if the token scope is `create update delete` without explicit `media`. +- The media sharp runtime patch makes image transformation resilient on FreeBSD: if `sharp` cannot load, uploads continue without resize/rotation instead of crashing the server process. +- The frontend sharp runtime patch makes icon generation non-fatal on FreeBSD when `sharp` cannot load, preventing startup crashes in asset controller imports. - The files upload route patch fixes browser multi-upload by posting to `/files/upload` (session-authenticated) instead of direct `/media` calls without bearer token. - The files upload locale patch adds missing `files.upload.dropText`/`files.upload.browse`/`files.upload.submitMultiple` labels in endpoint locale files so UI text does not render raw translation keys. - The frontend serviceworker patch ensures `@indiekit/frontend/lib/serviceworker.js` exists at runtime to avoid ENOENT in the offline/service worker route. diff --git a/package.json b/package.json index 5ab16e52..ea9b89d0 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs", - "serve": "node scripts/preflight-mongo-connection.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs", + "serve": "node scripts/preflight-mongo-connection.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-endpoint-media-sharp-runtime.mjs b/scripts/patch-endpoint-media-sharp-runtime.mjs new file mode 100644 index 00000000..b25bf190 --- /dev/null +++ b/scripts/patch-endpoint-media-sharp-runtime.mjs @@ -0,0 +1,112 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@indiekit/endpoint-media/lib/media-transform.js", + "node_modules/@indiekit/indiekit/node_modules/@indiekit/endpoint-media/lib/media-transform.js", +]; + +const oldImport = 'import sharp from "sharp";'; +const newImport = [ + 'import { createRequire } from "node:module";', + "", + "const require = createRequire(import.meta.url);", + "", + "let sharpModule;", + "let sharpLoadError;", + "", + "const getSharp = () => {", + " if (sharpModule) {", + " return sharpModule;", + " }", + "", + " if (sharpLoadError) {", + " return null;", + " }", + "", + " try {", + ' sharpModule = require("sharp");', + " return sharpModule;", + " } catch (error) {", + " sharpLoadError = error;", + " console.warn(", + ' "[postinstall] endpoint-media sharp unavailable (" +', + " (error.code || error.message) +", + ' "); image transform disabled",', + " );", + " return null;", + " }", + "};", +].join("\n"); + +const oldTransformBlock = ` const { resize } = imageProcessing; + + file.data = await sharp(file.data).rotate().resize(resize).toBuffer();`; + +const newTransformBlock = ` const sharp = getSharp(); + if (!sharp) { + return file; + } + + const resize = imageProcessing?.resize; + let pipeline = sharp(file.data).rotate(); + + if (resize) { + pipeline = pipeline.resize(resize); + } + + file.data = await pipeline.toBuffer();`; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let checked = 0; +let patched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checked += 1; + const source = await readFile(filePath, "utf8"); + + if (source.includes("const getSharp = () =>")) { + continue; + } + + let updated = source; + let changed = false; + + if (updated.includes(oldImport)) { + updated = updated.replace(oldImport, newImport); + changed = true; + } + + if (updated.includes(oldTransformBlock)) { + updated = updated.replace(oldTransformBlock, newTransformBlock); + changed = true; + } + + if (!changed) { + continue; + } + + await writeFile(filePath, updated, "utf8"); + patched += 1; +} + +if (checked === 0) { + console.log("[postinstall] No endpoint-media transform files found"); +} else if (patched === 0) { + console.log("[postinstall] endpoint-media sharp runtime patch already applied"); +} else { + console.log( + `[postinstall] Patched endpoint-media sharp runtime handling in ${patched} file(s)`, + ); +} diff --git a/scripts/patch-frontend-sharp-runtime.mjs b/scripts/patch-frontend-sharp-runtime.mjs new file mode 100644 index 00000000..e03e6078 --- /dev/null +++ b/scripts/patch-frontend-sharp-runtime.mjs @@ -0,0 +1,133 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@indiekit/frontend/lib/sharp.js", + "node_modules/@indiekit/indiekit/node_modules/@indiekit/frontend/lib/sharp.js", + "node_modules/@indiekit/endpoint-posts/node_modules/@indiekit/frontend/lib/sharp.js", + "node_modules/@rmdes/indiekit-endpoint-conversations/node_modules/@indiekit/frontend/lib/sharp.js", + "node_modules/@rmdes/indiekit-endpoint-webmention-io/node_modules/@indiekit/frontend/lib/sharp.js", +]; + +const marker = "const getSharp = () =>"; + +const replacement = `import fs from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import { icon } from "./globals/icon.js"; + +const require = createRequire(import.meta.url); +const fallbackPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+tm4cAAAAASUVORK5CYII=", + "base64", +); + +let sharpModule; +let sharpLoadError; + +const getSharp = () => { + if (sharpModule) { + return sharpModule; + } + + if (sharpLoadError) { + return null; + } + + try { + sharpModule = require("sharp"); + return sharpModule; + } catch (error) { + sharpLoadError = error; + console.warn( + "[postinstall] frontend sharp unavailable (" + + (error.code || error.message) + + "); app icon generation disabled", + ); + return null; + } +}; + +/** + * Get application icon image + * @param {string|number} size - Icon size + * @param {string} themeColor - Theme colour + * @param {string} [purpose] - Icon purpose (any, maskable or monochrome) + * @returns {Promise} File buffer + */ +export const appIcon = async (size, themeColor, purpose = "any") => { + const sharp = getSharp(); + if (!sharp) { + return fallbackPng; + } + + const svgPath = fileURLToPath( + new URL("../assets/app-icon-" + purpose + ".svg", import.meta.url), + ); + + const svg = fs.readFileSync(svgPath); + return sharp(svg) + .tint(themeColor) + .resize(Number(size)) + .png({ colours: 16 }) + .toBuffer(); +}; + +/** + * Get shortcut icon image + * @param {string|number} size - Icon size + * @param {string} name - Icon name + * @returns {Promise} PNG file + */ +export const shortcutIcon = async (size, name) => { + const sharp = getSharp(); + if (!sharp) { + return fallbackPng; + } + + return sharp(Buffer.from(icon(name))) + .resize(Number(size)) + .png({ colours: 16 }) + .toBuffer(); +}; +`; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let checked = 0; +let patched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checked += 1; + + const source = await readFile(filePath, "utf8"); + if (source.includes(marker)) { + continue; + } + + if (!source.includes('import sharp from "sharp";')) { + continue; + } + + await writeFile(filePath, replacement, "utf8"); + patched += 1; +} + +if (checked === 0) { + console.log("[postinstall] No frontend sharp files found"); +} else if (patched === 0) { + console.log("[postinstall] frontend sharp runtime patch already applied"); +} else { + console.log(`[postinstall] Patched frontend sharp runtime handling in ${patched} file(s)`); +} diff --git a/start.example.sh b/start.example.sh index 4f154c46..846fb487 100644 --- a/start.example.sh +++ b/start.example.sh @@ -40,6 +40,8 @@ export NODE_ENV="${NODE_ENV:-production}" # Ensure runtime dependency patches are applied even if node_modules already exists. /usr/local/bin/node scripts/patch-lightningcss.mjs /usr/local/bin/node scripts/patch-endpoint-media-scope.mjs +/usr/local/bin/node scripts/patch-endpoint-media-sharp-runtime.mjs +/usr/local/bin/node scripts/patch-frontend-sharp-runtime.mjs /usr/local/bin/node scripts/patch-endpoint-files-upload-route.mjs /usr/local/bin/node scripts/patch-endpoint-files-upload-locales.mjs /usr/local/bin/node scripts/patch-frontend-serviceworker-file.mjs