diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index badb9636..0f4e9075 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,13 @@ jobs: node-version: "22" cache: yarn - run: yarn install --frozen-lockfile + - uses: actions/checkout@v4 + with: + repository: Gryt-chat/image-worker + ref: e5c11d88ac75f32995dd06f90566c32e44a3bb04 + path: .worker-tests + - run: yarn install --frozen-lockfile && yarn build + working-directory: .worker-tests # The reason this workflow exists: ten test files and 87 tests that # nothing ran on a pull request. diff --git a/.gitignore b/.gitignore index 729ee21b..2c09ce90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.worker-tests/ dist dist-selfhosted dist-ssr @@ -36,4 +37,4 @@ src/**/*.js.map # Local runtime data (DATA_DIR): SQLite database, uploads, identity keys. # start_dev.sh points ws1/ws2 at ./data/ws1 and ./data/ws2. -data/ \ No newline at end of file +data/ diff --git a/eslint.config.mjs b/eslint.config.mjs index 2a38c9c4..7627b6c7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist/', 'dist-selfhosted/', '*.js'] }, + { ignores: ['dist/', 'dist-selfhosted/', '.worker-tests/', '*.js'] }, eslint.configs.recommended, tseslint.configs.recommended, { diff --git a/src/import/discord/media.test.ts b/src/import/discord/media.test.ts index 31cde9ab..8cb11410 100644 --- a/src/import/discord/media.test.ts +++ b/src/import/discord/media.test.ts @@ -39,7 +39,8 @@ describe("imported media", () => { const [picture, notes] = m!.attachments!; const file = await getFile(picture); assert.equal(file?.mime, "image/png"); - assert.equal(file?.width, 8); + assert.equal(file?.width, null, "dimensions stay untrusted until worker processing"); + assert.ok(file?.s3_key.startsWith("quarantine/")); assert.equal(file?.original_name, "cat.png"); const owner = getSqliteDb().prepare(`SELECT uploaded_by_server_user_id AS by FROM files WHERE file_id = ?`).get(picture) as { by: string }; assert.equal(owner.by, "discord:11"); diff --git a/src/jobs/emojiQueueWorker.ts b/src/jobs/emojiQueueWorker.ts index 699058d6..191d817f 100644 --- a/src/jobs/emojiQueueWorker.ts +++ b/src/jobs/emojiQueueWorker.ts @@ -1,5 +1,4 @@ import consola from "consola"; -import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; import { deleteObject, getObjectAsBuffer, putObject } from "../storage"; @@ -33,7 +32,6 @@ export function startEmojiQueueWorker(): void { const concurrency = clampInt(process.env.EMOJI_QUEUE_CONCURRENCY, 1, 1, 4); const pollMs = clampInt(process.env.EMOJI_QUEUE_POLL_MS, 750, 250, 5000); - sharp.concurrency(Math.max(1, Math.min(2, concurrency))); consola.info(`[EmojiQueue] Worker starting (concurrency=${concurrency}, pollMs=${pollMs})`); @@ -57,7 +55,7 @@ export function startEmojiQueueWorker(): void { const rawBuffer = await getObjectAsBuffer({ bucket, key: job.raw_s3_key }); - const { processed, ext, contentType } = await processEmojiToOptimizedImage(rawBuffer, job.raw_content_type.toLowerCase()); + const { processed, ext, contentType } = await processEmojiToOptimizedImage(rawBuffer, job.raw_content_type.toLowerCase(), job.uploaded_by_server_user_id); const latestAfterProcess = await getLatestEmojiJobIdByName(job.name); if (latestAfterProcess && latestAfterProcess !== jobId) { @@ -67,15 +65,13 @@ export function startEmojiQueueWorker(): void { return; } - const outKey = `emojis/${job.name}.${ext}`; const existing = await getEmoji(job.name); - if (existing) { - await deleteObject({ bucket, key: existing.s3_key }).catch((e) => consola.warn("S3 cleanup failed", e)); - } const fileId = uuidv4(); + const outKey = `emojis/${fileId}.${ext}`; await putObject({ bucket, key: outKey, body: processed, contentType }); await insertEmoji({ name: job.name, file_id: fileId, s3_key: outKey, uploaded_by_server_user_id: job.uploaded_by_server_user_id }); + if (existing) await deleteObject({ bucket, key: existing.s3_key }).catch((e) => consola.warn("S3 cleanup failed", e)); await updateEmojiJobStatus({ job_id: jobId, @@ -118,4 +114,3 @@ export function startEmojiQueueWorker(): void { tick().catch((e) => consola.warn("emoji queue tick failed", e)); }, pollMs); } - diff --git a/src/routes/bannerUpload.test.ts b/src/routes/bannerUpload.test.ts index 0273c21d..f67904fc 100644 --- a/src/routes/bannerUpload.test.ts +++ b/src/routes/bannerUpload.test.ts @@ -85,6 +85,19 @@ before(async () => { }); describe("shared chat upload processing", () => { + it("quarantines media even when the upload claims to be an ordinary attachment", async () => { + const who = await member("mislabelled-chat"); + for (const [bytes, expected] of [[await png(20, 20), "image/png"], [mp4(), "video/mp4"]] as const) { + const response = await upload(who, bytes, "application/octet-stream", "attachment.bin", ""); + assert.equal(response.status, 201, JSON.stringify(response.body)); + const fileId = response.body.fileId as string; + assert.equal(response.body.processing, true); + assert.equal((await getFile(fileId))?.mime, expected); + assert.equal((await getImageJob(fileId))?.status, "queued"); + const token = generateFileToken({ ...who, serverHost: host, tokenVersion: 0 }); + assert.equal((await fetch(`${base}/api/uploads/files/${fileId}?t=${encodeURIComponent(token)}`)).status, 503); + } + }); it("blocks new unencrypted pictures until the worker approves them", async () => { const who = await member("chat-picture"); const bytes = await png(16, 16); diff --git a/src/routes/emojiBttvImport.ts b/src/routes/emojiBttvImport.ts index dca84616..96202573 100644 --- a/src/routes/emojiBttvImport.ts +++ b/src/routes/emojiBttvImport.ts @@ -206,7 +206,6 @@ export function registerBttvRoutes(router: Router): void { const existingEmoji = usedNames.has(name) ? await getEmoji(name) : null; if (existingEmoji) { consola.debug("[BttvImport] Replacing existing emoji:", name); - await deleteObject({ bucket, key: existingEmoji.s3_key }).catch((e) => consola.warn("S3 delete failed", e)); } const cdnResp = await fetch(`${BTTV_CDN}/${emote.id}/3x`); @@ -220,12 +219,13 @@ export function registerBttvRoutes(router: Router): void { const sourceMime = emote.imageType === "gif" ? "image/gif" : emote.imageType === "webp" ? "image/webp" : "image/png"; - const { processed, ext, contentType } = await processEmojiToOptimizedImage(imgBuffer, sourceMime); + const { processed, ext, contentType } = await processEmojiToOptimizedImage(imgBuffer, sourceMime, serverUserId); const fileId = uuidv4(); - const key = `emojis/${name}.${ext}`; + const key = `emojis/${fileId}.${ext}`; await putObject({ bucket, key, body: processed, contentType }); await insertEmoji({ name, file_id: fileId, s3_key: key, uploaded_by_server_user_id: serverUserId }); + if (existingEmoji) await deleteObject({ bucket, key: existingEmoji.s3_key }).catch((e) => consola.warn("S3 delete failed", e)); usedNames.add(name); results.push({ name, file_id: fileId, ok: true }); diff --git a/src/routes/emojis.ts b/src/routes/emojis.ts index 5ef1edf0..6c9aeb31 100644 --- a/src/routes/emojis.ts +++ b/src/routes/emojis.ts @@ -163,20 +163,20 @@ emojisRouter.post( const existingEmoji = usedNames.has(entry.name) ? await getEmoji(entry.name) : null; if (existingEmoji) { consola.debug("[EmojiUpload] Replacing existing emoji:", entry.name); - await deleteObject({ bucket, key: existingEmoji.s3_key }).catch((e) => consola.warn("S3 delete failed", e)); } consola.debug("[EmojiUpload] Processing image:", { name: entry.name, mime: entry.mime, bufferSize: entry.buffer.length }); - const { processed, ext, contentType } = await processEmojiToOptimizedImage(entry.buffer, entry.mime.toLowerCase()); - consola.debug("[EmojiUpload] Sharp resize done:", { name: entry.name, ext, processedSize: processed.length }); + const { processed, ext, contentType } = await processEmojiToOptimizedImage(entry.buffer, entry.mime.toLowerCase(), serverUserId); + consola.debug("[EmojiUpload] Worker processing done:", { name: entry.name, ext, processedSize: processed.length }); const fileId = uuidv4(); - const key = `emojis/${entry.name}.${ext}`; + const key = `emojis/${fileId}.${ext}`; consola.debug("[EmojiUpload] Uploading to S3:", { key, bucket, contentType, size: processed.length }); await putObject({ bucket, key, body: processed, contentType }); consola.debug("[EmojiUpload] S3 upload done:", key); consola.debug("[EmojiUpload] Inserting into DB:", { name: entry.name, fileId, key, serverUserId }); await insertEmoji({ name: entry.name, file_id: fileId, s3_key: key, uploaded_by_server_user_id: serverUserId }); + if (existingEmoji) await deleteObject({ bucket, key: existingEmoji.s3_key }).catch((e) => consola.warn("S3 delete failed", e)); consola.debug("[EmojiUpload] DB insert done:", entry.name); usedNames.add(entry.name); diff --git a/src/routes/groupIconUpload.test.ts b/src/routes/groupIconUpload.test.ts index ad1d9d2d..5ecfe576 100644 --- a/src/routes/groupIconUpload.test.ts +++ b/src/routes/groupIconUpload.test.ts @@ -16,9 +16,12 @@ import { createServerConfigIfNotExists, setServerRole } from "../db/sqlite/serve import { getUserByServerId, upsertUser } from "../db/sqlite/users"; import { unreferencedAmong } from "../jobs/mediaSweep"; import { fileReadVerdict } from "../services/fileAccess"; -import { initStorage } from "../storage"; +import { getObjectAsBuffer, initStorage } from "../storage"; +import { getEmoji } from "../db"; +import { emojisRouter } from "./emojis"; import { generateAccessToken } from "../utils/jwt"; import { uploadsRouter } from "./uploads"; +import { startTestImageWorker } from "../testSupport/imageWorker"; /** * Choosing a group's picture went through the avatar route, which also made it @@ -29,6 +32,7 @@ let dir: string; let server: Server; let base = ""; let host = ""; +let stopWorker: () => Promise; interface Member { serverUserId: string; grytUserId: string; nickname: string } @@ -64,9 +68,11 @@ before(async () => { await initSqlite(); initStorage(); await createServerConfigIfNotExists(); + stopWorker = await startTestImageWorker(); const app = express(); app.use("/api/uploads", uploadsRouter); + app.use("/api/emojis", emojisRouter); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", () => resolve())); const { port } = server.address() as AddressInfo; @@ -74,7 +80,34 @@ before(async () => { base = `http://${host}`; }); +describe("worker-backed emoji replacements", () => { + it("keeps the existing emoji when the worker rejects a replacement", async () => { + const owner = await member("emoji-owner"); + await setServerRole(owner.serverUserId, "owner"); + const token = generateAccessToken({ ...owner, serverHost: host, tokenVersion: 0 }); + const send = async (bytes: Buffer) => { + const form = new FormData(); + form.append("name", "test_emoji"); + form.append("file", new Blob([new Uint8Array(bytes)], { type: "image/png" }), "emoji.png"); + return fetch(`${base}/api/emojis`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form }); + }; + assert.equal((await send(await png())).status, 201); + const original = (await getEmoji("test_emoji"))!; + const originalBytes = await getObjectAsBuffer({ bucket: "gryt-test", key: original.s3_key }); + assert.equal((await sharp(originalBytes).metadata()).width, 64); + const broken = (await png()).subarray(0, 32); + assert.equal((await send(broken)).status, 400); + assert.deepEqual(await getEmoji("test_emoji"), original); + assert.deepEqual(await getObjectAsBuffer({ bucket: "gryt-test", key: original.s3_key }), originalBytes); + assert.equal((await send(await png())).status, 201); + const replacement = (await getEmoji("test_emoji"))!; + assert.notEqual(replacement.s3_key, original.s3_key); + await assert.rejects(getObjectAsBuffer({ bucket: "gryt-test", key: original.s3_key })); + }); +}); + after(async () => { + await stopWorker?.(); await new Promise((resolve) => server.close(() => resolve())); delete process.env.DATA_DIR; delete process.env.S3_BUCKET; diff --git a/src/routes/routeJsonLimits.test.ts b/src/routes/routeJsonLimits.test.ts index 4b8746a6..1726d049 100644 --- a/src/routes/routeJsonLimits.test.ts +++ b/src/routes/routeJsonLimits.test.ts @@ -18,6 +18,7 @@ import { generateAccessToken } from "../utils/jwt"; import { emojisRouter } from "./emojis"; import { OWN_JSON_PARSERS, parsesOwnJson } from "./ownJsonParsers"; import { webhooksRouter } from "./webhooks"; +import { startTestImageWorker } from "../testSupport/imageWorker"; /** GRYT-1200: the app-wide 2 MB parser read these bodies first, so the routes' own 100 KB limit never applied. */ @@ -27,6 +28,7 @@ let dir: string; let server: Server; let base = ""; let token = ""; +let stopWorker: () => Promise; async function call(method: string, path: string, body: string | FormData) { const headers: Record = { Authorization: `Bearer ${token}` }; @@ -63,6 +65,7 @@ before(async () => { await initSqlite(); initStorage(); await createServerConfigIfNotExists(); + stopWorker = await startTestImageWorker(); const user = await upsertUser("acct-owner", "owner"); await setServerRole(user.server_user_id, "owner"); @@ -80,6 +83,7 @@ before(async () => { }); after(async () => { + await stopWorker?.(); await new Promise((resolve) => server.close(() => resolve())); delete process.env.DATA_DIR; delete process.env.S3_BUCKET; diff --git a/src/routes/server.ts b/src/routes/server.ts index 1259a6c1..e318862d 100644 --- a/src/routes/server.ts +++ b/src/routes/server.ts @@ -2,7 +2,6 @@ import consola from "consola"; import express from "express"; import type { Request, Response, NextFunction } from "express"; import multer from "multer"; -import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; import { deleteObject, putObject } from "../storage"; @@ -13,7 +12,7 @@ import { updateServerConfig, } from "../db"; import { broadcastServerUiUpdate } from "../socket"; -import { MAX_INPUT_PIXELS, validateImage } from "../utils/imageValidation"; +import { consumeRasterUpload, RasterProcessingError } from "../services/rasterUpload"; import { sanitizeSvg } from "../utils/svgSanitize"; import { verifyAccessToken } from "../utils/jwt"; import { requireBearerToken } from "../middleware/requireBearerToken"; @@ -203,76 +202,18 @@ serverRouter.post( return; } - const isAnimated = - iconMime === "image/gif" || - iconMime === "image/webp" || - iconMime === "image/avif"; - - const validation = await validateImage(file.buffer, { - animated: isAnimated, - }); - if (!validation.valid) { - res - .status(400) - .json({ error: "invalid_file", message: validation.reason }); - return; - } - - // Drawn at 38 pixels on every server somebody joined, so an uncapped GIF - // is hundreds of kilobytes each. Refused, not truncated. - const frames = validation.pages ?? 1; - if (isAnimated && frames > MAX_ICON_FRAMES) { - res.status(400).json({ - error: "too_many_frames", - message: - `This icon has ${frames} frames and the limit is ${MAX_ICON_FRAMES}` + - ` — about ${MAX_ICON_FRAMES / 60} seconds at 60fps. Shorten it or` + - ` lower its frame rate and upload it again.`, - }); - return; - } - - // AVIF holds one frame and sharp stacks an animation into a tall strip, so - // a 95-frame GIF became a 256x9728 still. Same branch as emojiProcessing. - const outMime = isAnimated ? "image/webp" : "image/avif"; - const outExt = isAnimated ? "webp" : "avif"; - - let out: Buffer; + let key: string; try { - const pipeline = sharp(file.buffer, { - animated: isAnimated, - failOn: "error", - // validateImage pixel-checks one page; this decodes every frame, and a - // modest frame is under the ceiling where two hundred are not. - limitInputPixels: MAX_INPUT_PIXELS, - }).resize(256, 256, { fit: "cover" }); - - out = isAnimated - ? await pipeline.webp().toBuffer() - : await pipeline.avif().toBuffer(); - } catch { - res.status(400).json({ - error: "invalid_file", - message: - "Could not process image. Please upload a valid PNG/JPEG/WebP/GIF/AVIF under the size limit.", + const { processed, ext, contentType } = await consumeRasterUpload({ + bucket, bytes: file.buffer, uploadedBy: decoded.serverUserId, originalName: file.originalname || null, + profile: "icon", width: 256, height: 256, thumbWidth: 128, thumbHeight: 128, + maxFrames: MAX_ICON_FRAMES, maxBytes: iconMaxBytes, }); - return; - } - - const key = `server-icons/${safeHost}/${uuidv4()}.${outExt}`; - try { - await putObject({ bucket, key, body: out, contentType: outMime }); - } catch (e) { - const raw = e instanceof Error ? e.message : ""; - consola.error("icon upload s3 error", { bucket, key, message: raw }); - const friendly = /InvalidBucketName|NoSuchBucket|bucket/i.test(raw) - ? "File storage is misconfigured on this server. Please contact the server administrator." - : /AccessDenied|Forbidden/i.test(raw) - ? "File storage access denied. Please contact the server administrator." - : raw.trim().length > 0 - ? `Icon upload failed: ${raw}` - : "Icon upload failed due to a storage error."; - res.status(502).json({ error: "s3_error", message: friendly }); + key = `server-icons/${safeHost}/${uuidv4()}.${ext}`; + await putObject({ bucket, key, body: processed, contentType }); + } catch (error) { + const status = error instanceof RasterProcessingError ? error.status : 502; + res.status(status).json({ error: "image_processing_failed", message: "The server icon could not be processed." }); return; } diff --git a/src/routes/webhookAvatar.test.ts b/src/routes/webhookAvatar.test.ts index ed80bfc7..5c4b28e2 100644 --- a/src/routes/webhookAvatar.test.ts +++ b/src/routes/webhookAvatar.test.ts @@ -18,6 +18,8 @@ import { getUserByServerId, upsertUser } from "../db/sqlite/users"; import { unreferencedAmong } from "../jobs/mediaSweep"; import { fileReadVerdict } from "../services/fileAccess"; import { realMediaDeps } from "../services/webhookMedia"; +import { waitForRaster } from "../services/rasterUpload"; +import { startTestImageWorker } from "../testSupport/imageWorker"; import { getObject, initStorage } from "../storage"; import { generateAccessToken } from "../utils/jwt"; import { uploadsRouter } from "./uploads"; @@ -32,6 +34,7 @@ let dir: string; let server: Server; let base = ""; let host = ""; +let stopWorker: () => Promise; let owner: Member; let big: Buffer; @@ -103,6 +106,7 @@ before(async () => { await initSqlite(); initStorage(); await createServerConfigIfNotExists(); + stopWorker = await startTestImageWorker(); big = await sharp({ create: { width: 1600, height: 1200, channels: 3, background: { r: 40, g: 90, b: 200 } } }) .png() .toBuffer(); @@ -125,6 +129,7 @@ before(async () => { }); after(async () => { + await stopWorker?.(); setWebhookMediaDepsForTests(null); await new Promise((resolve) => server.close(() => resolve())); delete process.env.DATA_DIR; @@ -240,7 +245,7 @@ describe("a webhook post's avatar_url", () => { assert.ok(avatar.thumbnail_key, "no thumbnail"); const image = await getFile(message.cards![0].image_file_id!); - assert.equal(image?.mime, "image/png"); + assert.equal(image?.mime, "image/webp"); assert.deepEqual([image?.width, image?.height], [1600, 1200]); // The rules GRYT-1186 gave every picture a webhook sends still hold for this one. @@ -259,7 +264,8 @@ describe("a webhook post's avatar_url", () => { assert.ok(first.sender_avatar_file_id); assert.equal(first.sender_avatar_file_id, second.sender_avatar_file_id); // The same bytes as a card picture are a different file, and stay one on the second post. - const image = await getFile(second.cards![0].image_file_id!); - assert.equal(image?.mime, "image/png", "the second post's card picture is the resized avatar"); + const image = await waitForRaster(second.cards![0].image_file_id!); + assert.equal(image.mime, "image/webp"); + assert.deepEqual([image.width, image.height], [1600, 1200], "the card picture must not use the avatar's crop"); }); }); diff --git a/src/routes/webhookCards.test.ts b/src/routes/webhookCards.test.ts index 45e191cd..31759ea6 100644 --- a/src/routes/webhookCards.test.ts +++ b/src/routes/webhookCards.test.ts @@ -19,6 +19,7 @@ import { getMessagesCached } from "../socket/utils/messageCache"; import { initStorage } from "../storage"; import { generateAccessToken } from "../utils/jwt"; import { setWebhookMediaDepsForTests, webhooksRouter } from "./webhooks"; +import { startTestImageWorker } from "../testSupport/imageWorker"; /** GRYT-1186: cards, their pictures fetched once and stored, and a name that survives a reload. */ @@ -28,6 +29,7 @@ let base = ""; let token = ""; let picture: Buffer; let fetches: string[] = []; +let stopWorker: () => Promise; const respond = async (url: string): Promise => { fetches.push(url); @@ -58,6 +60,7 @@ before(async () => { await initSqlite(); initStorage(); await createServerConfigIfNotExists(); + stopWorker = await startTestImageWorker(); picture = await sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 60, g: 110, b: 140 } } }).png().toBuffer(); const user = await upsertUser("acct-owner", "owner"); @@ -76,6 +79,7 @@ before(async () => { beforeEach(() => { fetches = []; }); after(async () => { + await stopWorker?.(); setWebhookMediaDepsForTests(null); await new Promise((resolve) => server.close(() => resolve())); delete process.env.DATA_DIR; diff --git a/src/services/avatarImage.ts b/src/services/avatarImage.ts index 02330012..cfaa1789 100644 --- a/src/services/avatarImage.ts +++ b/src/services/avatarImage.ts @@ -1,211 +1,31 @@ -import consola from "consola"; -import sharp from "sharp"; -import { v4 as uuidv4 } from "uuid"; - import { AVATAR_MAX_PX, AVATAR_THUMB_PX } from "../constants/media"; +import { processRasterUpload, RasterProcessingError } from "./rasterUpload"; -/** The box a picture is cut to, and its thumbnail's. Avatars are square. */ export interface PictureBox { width: number; height: number; thumbWidth: number; thumbHeight: number } const AVATAR_BOX: PictureBox = { width: AVATAR_MAX_PX, height: AVATAR_MAX_PX, thumbWidth: AVATAR_THUMB_PX, thumbHeight: AVATAR_THUMB_PX }; -import { insertFile, updateFileRecord } from "../db"; -import { deleteObject, putObject } from "../storage"; -import { findDominantColor, validateImage, MAX_INPUT_PIXELS } from "../utils/imageValidation"; export type StoredAvatarPicture = | { ok: true; fileId: string; processing: boolean } - | { ok: false; status: 400 | 502; error: "invalid_file" | "s3_error"; message: string }; + | { ok: false; status: 400 | 502 | 503; error: "invalid_file" | "s3_error"; message: string }; -/** Every raster avatar and banner is stored through here, cut to its box with a - thumbnail. SVG must not reach it: sharp renders SVG through librsvg. */ export async function storeAvatarPicture(input: { bucket: string; bytes: Buffer; - /** As claimed. `validateImage` checks what the bytes really are. */ mime: string; originalName: string | null; uploadedBy: string; - /** An animation this size or under, within the pixel bound, is kept as sent. */ maxBytes: number; - /** Names the picture in a storage error, e.g. "Avatar". */ what: string; - /** A square avatar unless given. `prefix` is the storage folder. */ box?: PictureBox; prefix?: string; }): Promise { - const { bucket, bytes, originalName, uploadedBy, maxBytes, what } = input; - const box = input.box ?? AVATAR_BOX; - const prefix = input.prefix ?? "avatars"; - const fileId = uuidv4(); - const inputMime = input.mime.toLowerCase(); - const isAnimated = inputMime === "image/gif" || inputMime === "image/webp"; - const animExt = inputMime === "image/gif" ? "gif" : "webp"; - - let key: string; - let storedBody: Buffer; - let storedMime: string; - let storedSize: number; - let width: number | null = null; - let height: number | null = null; - let thumbKey: string | null = null; - let processing = false; - - const validation = await validateImage(bytes, { animated: isAnimated }); - if (!validation.valid) { - return { ok: false, status: 400, error: "invalid_file", message: validation.reason }; - } - width = validation.width; - height = validation.height; - - // Dimensions, not bytes: what is left is the modestly-sized animated - // avatar with large dimensions. - const withinBounds = - bytes.length <= maxBytes && - (width ?? 0) <= box.width && - (height ?? 0) <= box.height; - - if (isAnimated && withinBounds) { - key = `${prefix}/${fileId}.${animExt}`; - storedBody = bytes; - storedMime = inputMime; - storedSize = bytes.length; - - const thumb = await sharp(bytes, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) - .toBuffer() - .catch(() => null); - - if (thumb) { - thumbKey = `${prefix}/thumb_${fileId}.avif`; - await putObject({ bucket, key: thumbKey, body: thumb, contentType: "image/avif" }).catch((e) => { - console.error("avatar_thumb_s3_error", { bucket, key: thumbKey, message: (e instanceof Error ? e.message : "S3 upload failed.") }); - thumbKey = null; - }); - } - } else if (isAnimated) { - key = `${prefix}/${fileId}.avif`; - processing = true; - try { - storedBody = await sharp(bytes, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.width, height: box.height, fit: "cover" }) - .avif() - .toBuffer(); - } catch { - return { ok: false, status: 400, error: "invalid_file", message: "Could not process image." }; - } - storedMime = "image/avif"; - storedSize = storedBody.length; - // What was stored, not what was uploaded: `cover` crops to exactly - // this box, so the original describes a file that is gone. - width = box.width; - height = box.height; - } else { - key = `${prefix}/${fileId}.avif`; - try { - storedBody = await sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.width, height: box.height, fit: "cover" }) - .avif() - .toBuffer(); - } catch { - return { ok: false, status: 400, error: "invalid_file", message: "Could not process image. Please upload a valid image under the size limit." }; - } - storedMime = "image/avif"; - storedSize = storedBody.length; - width = box.width; - height = box.height; - - const thumb = await sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) - .toBuffer() - .catch(() => null); - - if (thumb) { - thumbKey = `${prefix}/thumb_${fileId}.avif`; - await putObject({ bucket, key: thumbKey, body: thumb, contentType: "image/avif" }).catch((e) => { - console.error("avatar_thumb_s3_error", { bucket, key: thumbKey, message: (e instanceof Error ? e.message : "S3 upload failed.") }); - thumbKey = null; - }); - } - } - try { - await putObject({ bucket, key, body: storedBody, contentType: storedMime }); - } catch (e) { - const raw = (e instanceof Error && e.message.trim().length > 0) ? e.message : ""; - console.error("avatar_upload_s3_error", { bucket, key, message: raw }); - const friendly = - /InvalidBucketName|NoSuchBucket|bucket/i.test(raw) - ? "File storage is misconfigured on this server. Please contact the server administrator." - : /AccessDenied|Forbidden/i.test(raw) - ? "File storage access denied. Please contact the server administrator." - : raw.length > 0 - ? `${what} upload failed: ${raw}` - : `${what} upload failed due to a storage error.`; - return { ok: false, status: 502, error: "s3_error", message: friendly }; + const box = input.box ?? AVATAR_BOX; + const file = await processRasterUpload({ ...box, bucket: input.bucket, bytes: input.bytes, uploadedBy: input.uploadedBy, + originalName: input.originalName, maxBytes: input.maxBytes, profile: "avatar" }); + return { ok: true, fileId: file.file_id, processing: false }; + } catch (error) { + if (error instanceof RasterProcessingError) return { ok: false, status: error.status, error: "invalid_file", message: error.message }; + return { ok: false, status: 502, error: "s3_error", message: `${input.what} could not be stored.` }; } - - // From the original upload, not `storedBody`, which for an oversized - // animated avatar is a placeholder replaced further down. - const dominantColor = await findDominantColor(bytes, { animated: isAnimated }); - - await insertFile({ - file_id: fileId, - s3_key: key, - mime: storedMime, - size: storedSize, - width, - height, - thumbnail_key: thumbKey, - thumbnail_px: thumbKey ? box.thumbWidth : null, - original_name: originalName, - dominant_color: dominantColor, - uploaded_by_server_user_id: uploadedBy, - created_at: new Date(), - }); - - // Background: resize oversized animated file and replace the placeholder - if (processing) { - const animBuf = bytes; - setImmediate(() => { - (async () => { - try { - const outputFormat = inputMime === "image/gif" ? "gif" : "webp"; - const outputMime = `image/${outputFormat}`; - // Every frame is decoded here, unlike the single-page check - // that let this buffer through, so the ceiling comes with it. - const pipeline = sharp(animBuf, { animated: true, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.width, height: box.height, fit: "cover" }); - const resized = outputFormat === "gif" - ? await pipeline.gif().toBuffer() - : await pipeline.webp().toBuffer(); - - const animKey = `${prefix}/${fileId}.${outputFormat}`; - await putObject({ bucket, key: animKey, body: resized, contentType: outputMime }); - - const thumbBuf = await sharp(resized, { pages: 1, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ width: box.thumbWidth, height: box.thumbHeight, fit: "cover" }) - .avif({ quality: 50 }) - .toBuffer() - .catch(() => null); - const newThumbKey = thumbBuf ? `${prefix}/thumb_${fileId}.avif` : null; - if (thumbBuf && newThumbKey) { - await putObject({ bucket, key: newThumbKey, body: thumbBuf, contentType: "image/avif" }).catch(() => {}); - } - - await updateFileRecord(fileId, { s3_key: animKey, mime: outputMime, size: resized.length, thumbnail_key: newThumbKey, thumbnail_px: newThumbKey ? box.thumbWidth : null }); - - if (animKey !== key) { - await deleteObject({ bucket, key }).catch(() => {}); - } - - consola.info(`Background avatar processing done for ${fileId} (${(resized.length / 1024).toFixed(0)}KB ${outputFormat})`); - } catch (err) { - consola.error(`Background avatar processing failed for ${fileId}`, err); - } - })(); - }); - } - - return { ok: true, fileId, processing }; } diff --git a/src/services/rasterUpload.ts b/src/services/rasterUpload.ts new file mode 100644 index 00000000..bd740874 --- /dev/null +++ b/src/services/rasterUpload.ts @@ -0,0 +1,78 @@ +import { v4 as uuidv4 } from "uuid"; +import { deleteFileRecord, getFile, getImageJob, insertFile, insertImageJob } from "../db"; +import { deleteObject, getObjectAsBuffer, putObject } from "../storage"; +import { mediaMimeFromHeader } from "../utils/mediaSignature"; + +export class RasterProcessingError extends Error { + constructor(readonly status: 400 | 503, message: string) { super(message); } +} + +export interface RasterUpload { + bucket: string; + bytes: Buffer; + uploadedBy: string | null; + originalName: string | null; + profile: "avatar" | "emoji" | "icon"; + width: number; + height: number; + thumbWidth: number; + thumbHeight: number; + maxFrames?: number; + maxBytes: number; + timeoutMs?: number; +} + +export async function waitForRaster(fileId: string, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const job = await getImageJob(fileId); + if (!job) throw new RasterProcessingError(503, "Image processing is unavailable."); + if (job.status === "error") throw new RasterProcessingError(400, "Image processing rejected this file."); + if (job.status === "done") { + const file = await getFile(fileId); + if (!file || !["image/avif", "image/webp"].includes(file.mime ?? "") || !file.width || !file.height || file.s3_key.startsWith("quarantine/")) throw new RasterProcessingError(503, "Image processing did not produce a usable file."); + return file; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new RasterProcessingError(503, "Image processing is taking too long. Try again later."); +} + +export async function processRasterUpload(input: RasterUpload) { + const mime = mediaMimeFromHeader(input.bytes.subarray(0, 32)); + if (!mime?.startsWith("image/")) throw new RasterProcessingError(400, "Unsupported image format."); + if (input.bytes.length > 64 * 1024 * 1024 || (input.maxBytes > 0 && input.bytes.length > input.maxBytes)) throw new RasterProcessingError(400, "Image exceeds the upload limit."); + const values = [input.width, input.height, input.thumbWidth, input.thumbHeight]; + if (values.some((value) => !Number.isSafeInteger(value) || value < 1 || value > 2048)) throw new RasterProcessingError(400, "Invalid image dimensions."); + const frames = input.maxFrames ?? 480; + if (!Number.isSafeInteger(frames) || frames < 1 || frames > 480) throw new RasterProcessingError(400, "Invalid animation limit."); + const fileId = uuidv4(); + const key = `quarantine/transforms/${input.profile}/${values.join("-")}-${frames}/${fileId}`; + await putObject({ bucket: input.bucket, key, body: input.bytes, contentType: mime }); + try { + await insertFile({ file_id: fileId, s3_key: key, mime, size: input.bytes.length, width: null, height: null, + thumbnail_key: null, thumbnail_px: input.thumbWidth, original_name: input.originalName, + uploaded_by_server_user_id: input.uploadedBy, created_at: new Date() }); + await insertImageJob({ job_id: fileId, file_id: fileId, raw_s3_key: key, raw_content_type: mime, raw_bytes: input.bytes.length }); + } catch (error) { + await deleteObject({ bucket: input.bucket, key }).catch(() => undefined); + await deleteFileRecord(fileId).catch(() => undefined); + throw error; + } + const file = await waitForRaster(fileId, input.timeoutMs); + if (input.profile !== "emoji" && (file.width !== input.width || file.height !== input.height)) throw new RasterProcessingError(503, "Image worker does not support the requested crop."); + if (input.maxBytes > 0 && (file.size ?? 0) > input.maxBytes) throw new RasterProcessingError(400, "Processed image exceeds the upload limit."); + return file; +} + +export async function consumeRasterUpload(input: RasterUpload) { + const file = await processRasterUpload(input); + try { + const processed = await getObjectAsBuffer({ bucket: input.bucket, key: file.s3_key }); + return { processed, ext: file.mime === "image/avif" ? "avif" : "webp", contentType: file.mime! }; + } finally { + await deleteObject({ bucket: input.bucket, key: file.s3_key }); + if (file.thumbnail_key) await deleteObject({ bucket: input.bucket, key: file.thumbnail_key }); + await deleteFileRecord(file.file_id); + } +} diff --git a/src/services/storeUploadedFile.ts b/src/services/storeUploadedFile.ts index c83abc22..495859c3 100644 --- a/src/services/storeUploadedFile.ts +++ b/src/services/storeUploadedFile.ts @@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { insertFile, insertImageJob } from "../db"; import { storageForUpload } from "../routes/uploadStorage"; import { deleteObject, putObject } from "../storage"; -import { validateImage } from "../utils/imageValidation"; +import { mediaMimeFromFile } from "../utils/mediaSignature"; import { sanitizeSvg } from "../utils/svgSanitize"; import { PARSE_LIMITS, readVideoDimensionsFromFile } from "../utils/videoDimensions"; @@ -48,7 +48,7 @@ export async function storeUploadedFile(input: { const storage = storageForUpload({ sealed: input.sealed, fileId, - mimetype: input.mimetype, + mimetype: input.sealed ? input.mimetype : (await mediaMimeFromFile(path)) ?? input.mimetype, originalName: input.originalName, }); @@ -109,19 +109,9 @@ export async function storeUploadedFile(input: { }; } - const imageBytes = await readFile(path); - const validation = await validateImage(imageBytes, { animated: true }); - if (!validation.valid) return { ok: false, status: 400, error: "invalid_file", message: validation.reason }; - - width = positiveInt(input.claimedWidth); - height = positiveInt(input.claimedHeight); - - // `validateImage` already read the dimensions off the same decode. - // The second library was `image-size`, whose advisory has no fix. - if (!width || !height) { - width = validation.width; - height = validation.height; - } + // Dimensions and validity come from the worker; do not decode here. + width = null; + height = null; } // The whole point of the exercise: the bytes go from the temp file diff --git a/src/services/webhookMedia.test.ts b/src/services/webhookMedia.test.ts index 7e4c2a92..426a341d 100644 --- a/src/services/webhookMedia.test.ts +++ b/src/services/webhookMedia.test.ts @@ -4,6 +4,7 @@ import type { AddressInfo } from "node:net"; import { after, before, describe, it } from "node:test"; import sharp from "sharp"; +import { validateImage } from "../utils/imageValidation"; import { webhookMessageSchema } from "../routes/webhookSchemas"; import { fallbackText, realMediaDeps, resolveWebhookMedia, sniffImageFormat, type FetchOutcome, type MediaDeps } from "./webhookMedia"; @@ -15,7 +16,10 @@ function fakeDeps(respond: (url: string, max: number, signal: AbortSignal) => Pr const stored: ("picture" | "avatar")[] = []; const deps: MediaDeps = { fetchBytes: async (url, signal, max) => { fetched.push(url); return respond(url, max, signal); }, - storeImage: async () => { stored.push("picture"); return `file-${stored.length}`; }, + storeImage: async (_id, bytes) => { + if (!(await validateImage(bytes, { animated: true })).valid) throw new Error("Fixture worker rejected image"); + stored.push("picture"); return `file-${stored.length}`; + }, storeAvatar: async () => { stored.push("avatar"); return `avatar-${stored.length}`; }, }; return { deps, fetched, stored }; @@ -85,7 +89,7 @@ describe("resolveWebhookMedia", () => { ["cards[0].image_url", "blocked"], ["cards[1].thumbnail_url", "unsupported_type"], ["cards[1].image_url", "too_large"], - ["cards[2].thumbnail_url", "invalid_image"], + ["cards[2].thumbnail_url", "store_failed"], ["cards[2].image_url", "unsupported_type"], ], ); diff --git a/src/services/webhookMedia.ts b/src/services/webhookMedia.ts index f290deee..b3365ad0 100644 --- a/src/services/webhookMedia.ts +++ b/src/services/webhookMedia.ts @@ -15,10 +15,10 @@ import type { StoredWebhookCard } from "../db"; import type { PayloadWarning, WebhookCardInput, WebhookMessageInput } from "../routes/webhookSchemas"; import { colorToHex } from "../routes/webhookSchemas"; import { storageForUpload } from "../routes/uploadStorage"; -import { putObject } from "../storage"; -import { validateImage } from "../utils/imageValidation"; +import { deleteObject, putObject } from "../storage"; import { fetchFollowingSafely } from "../utils/safePreviewFetch"; import { storeAvatarPicture } from "./avatarImage"; +import { waitForRaster } from "./rasterUpload"; /** The whole request's picture fetching, so a slow host can't hold a webhook call open. */ export const MEDIA_DEADLINE_MS = 12_000; @@ -123,10 +123,11 @@ async function storeWebhookImage(webhookId: string, bytes: Buffer, format: Image const bucket = process.env.S3_BUCKET as string; const fileId = uuidv4(); const storage = storageForUpload({ sealed: false, fileId, mimetype: MIME[format], originalName: `webhook.${format}` }); - await putObject({ bucket, key: storage.key, body: bytes, contentType: storage.storedMime }); + const key = `quarantine/${storage.key}`; + await putObject({ bucket, key, body: bytes, contentType: storage.storedMime }); await insertFile({ file_id: fileId, - s3_key: storage.key, + s3_key: key, mime: storage.storedMime, size: bytes.length, width, @@ -138,9 +139,14 @@ async function storeWebhookImage(webhookId: string, bytes: Buffer, format: Image created_at: new Date(), }); if (storage.queueImageJob) { - await insertImageJob({ file_id: fileId, raw_s3_key: storage.key, raw_content_type: storage.storedMime, raw_bytes: bytes.length }) - .catch((e: unknown) => consola.warn("Failed to queue webhook image job", e)); + try { + await insertImageJob({ job_id: fileId, file_id: fileId, raw_s3_key: key, raw_content_type: storage.storedMime, raw_bytes: bytes.length }); + } catch (error) { + await deleteObject({ bucket, key }).catch(() => undefined); + throw error; + } } + await waitForRaster(fileId, MEDIA_DEADLINE_MS); await setWebhookMediaFileId(webhookId, sha256, fileId); return fileId; } @@ -240,8 +246,6 @@ export async function resolveWebhookMedia( if (!format) { entry.code = "unsupported_type"; return; } if (fetched.bytes.length > budget) { entry.code = "media_budget"; return; } budget -= fetched.bytes.length; - const checked = await validateImage(fetched.bytes, { animated: true }); - if (!checked.valid) { entry.code = "invalid_image"; return; } const store = async (save: () => Promise): Promise => { try { return await save(); @@ -252,7 +256,7 @@ export async function resolveWebhookMedia( } }; // Downloaded once, but the avatar and a card picture from the same URL are two files. - if (entry.asPicture) entry.fileId = await store(() => deps.storeImage(webhookId, fetched.bytes, format, checked.width, checked.height)); + if (entry.asPicture) entry.fileId = await store(() => deps.storeImage(webhookId, fetched.bytes, format, 0, 0)); if (entry.asAvatar) entry.avatarFileId = await store(() => deps.storeAvatar(webhookId, fetched.bytes, format)); }); } finally { diff --git a/src/testSupport/imageWorker.ts b/src/testSupport/imageWorker.ts new file mode 100644 index 00000000..fec7f10d --- /dev/null +++ b/src/testSupport/imageWorker.ts @@ -0,0 +1,40 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createServer } from "node:net"; +import { join } from "node:path"; + +export async function startTestImageWorker(): Promise<() => Promise> { + const entry = process.env.GRYT_TEST_IMAGE_WORKER_ENTRY ?? join(process.cwd(), ".worker-tests/dist/index.js"); + if (!existsSync(entry)) throw new Error("Build the image-worker fixture and set GRYT_TEST_IMAGE_WORKER_ENTRY before running raster integration tests."); + const listener = createServer(); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("Missing temporary health port"); + await new Promise((resolve) => listener.close(() => resolve())); + const child = spawn(process.execPath, [entry], { + env: { PATH: process.env.PATH, NODE_ENV: "development", DATA_DIR: process.env.DATA_DIR, S3_BUCKET: process.env.S3_BUCKET, + STORAGE_BACKEND: "filesystem", HEALTH_PORT: String(address.port), IMAGE_WORKER_POLL_MS: "250" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (bytes: Buffer) => { output = (output + bytes.toString()).slice(-8000); }); + child.stderr.on("data", (bytes: Buffer) => { output = (output + bytes.toString()).slice(-8000); }); + const stop = async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once("exit", resolve)); + child.kill("SIGTERM"); + await exited; + }; + try { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(output); + if (output.includes("Polling started")) return stop; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Image-worker fixture did not start: ${output}`); + } catch (error) { + await stop(); + throw error; + } +} diff --git a/src/utils/emojiProcessing.ts b/src/utils/emojiProcessing.ts index 22d206dd..39fb4287 100644 --- a/src/utils/emojiProcessing.ts +++ b/src/utils/emojiProcessing.ts @@ -1,35 +1,10 @@ -import sharp from "sharp"; - -import { MAX_INPUT_PIXELS, validateImage } from "./imageValidation"; - -const ANIMATED_MIME_SET = new Set(["image/gif", "image/webp", "image/avif"]); +import { consumeRasterUpload } from "../services/rasterUpload"; export async function processEmojiToOptimizedImage( buffer: Buffer, - mime: string, + _mime: string, + uploadedBy: string | null = null, ): Promise<{ processed: Buffer; ext: string; contentType: string }> { - const animated = ANIMATED_MIME_SET.has(mime); - const startedAt = Date.now(); - console.log("[EmojiProcess] start", { mime, animated, bytes: buffer.length }); - - const validation = await validateImage(buffer, { animated }); - if (!validation.valid) { - throw new Error(validation.reason); - } - - // validateImage decodes one page; this decodes all of them, so it carries - // the ceiling rather than relying on the check that came before it. - const pipeline = sharp(buffer, { animated, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }) - .resize({ height: 128, withoutEnlargement: true }); - - if (animated) { - const processed = await pipeline.webp({ effort: 6 }).toBuffer(); - console.log("[EmojiProcess] done", { mime, animated, outExt: "webp", outBytes: processed.length, ms: Date.now() - startedAt }); - return { processed, ext: "webp", contentType: "image/webp" }; - } - - const processed = await pipeline.avif().toBuffer(); - console.log("[EmojiProcess] done", { mime, animated, outExt: "avif", outBytes: processed.length, ms: Date.now() - startedAt }); - return { processed, ext: "avif", contentType: "image/avif" }; + return consumeRasterUpload({ bucket: process.env.S3_BUCKET as string, bytes: buffer, uploadedBy, originalName: null, + profile: "emoji", width: 128, height: 128, thumbWidth: 128, thumbHeight: 128, maxBytes: 64 * 1024 * 1024 }); } - diff --git a/src/utils/mediaSignature.test.ts b/src/utils/mediaSignature.test.ts new file mode 100644 index 00000000..85b22673 --- /dev/null +++ b/src/utils/mediaSignature.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mediaMimeFromHeader } from "./mediaSignature"; + +test("detects media headers without invoking a decoder or trusting a filename", () => { + const examples: [Buffer, string][] = [ + [Buffer.from([255, 216, 255]), "image/jpeg"], + [Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), "image/png"], + [Buffer.from("GIF89a"), "image/gif"], + [Buffer.from("RIFF0000WEBP"), "image/webp"], + [Buffer.from([73, 73, 42, 0]), "image/tiff"], + [Buffer.from("0000ftypavif"), "image/avif"], + [Buffer.from("0000ftypheic"), "image/heif"], + [Buffer.from("0000ftypisom"), "video/mp4"], + [Buffer.from([26, 69, 223, 163]), "video/webm"], + ]; + for (const [bytes, expected] of examples) assert.equal(mediaMimeFromHeader(bytes), expected); + for (const bytes of [Buffer.alloc(0), Buffer.from(""), Buffer.from(""), Buffer.from("RIFF")]) assert.equal(mediaMimeFromHeader(bytes), null); +}); diff --git a/src/utils/mediaSignature.ts b/src/utils/mediaSignature.ts new file mode 100644 index 00000000..2f291745 --- /dev/null +++ b/src/utils/mediaSignature.ts @@ -0,0 +1,28 @@ +import { open } from "node:fs/promises"; + +export function mediaMimeFromHeader(bytes: Buffer): string | null { + if (bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) return "image/jpeg"; + if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"; + if (/^GIF8[79]a/.test(bytes.subarray(0, 6).toString("ascii"))) return "image/gif"; + if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; + if (bytes.subarray(0, 4).equals(Buffer.from([73, 73, 42, 0])) || bytes.subarray(0, 4).equals(Buffer.from([77, 77, 0, 42]))) return "image/tiff"; + if (bytes.subarray(4, 8).toString("ascii") === "ftyp") { + const brands = bytes.subarray(8, 32).toString("ascii"); + if (/avif|avis/.test(brands)) return "image/avif"; + if (/heic|heix|hevc|hevx|mif1|msf1/.test(brands)) return "image/heif"; + return "video/mp4"; + } + if (bytes.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))) return "video/webm"; + return null; +} + +export async function mediaMimeFromFile(path: string): Promise { + const file = await open(path, "r"); + try { + const header = Buffer.alloc(32); + const { bytesRead } = await file.read(header, 0, header.length, 0); + return mediaMimeFromHeader(header.subarray(0, bytesRead)); + } finally { + await file.close(); + } +} diff --git a/src/utils/rasterHeaderDimensions.test.ts b/src/utils/rasterHeaderDimensions.test.ts new file mode 100644 index 00000000..be558f44 --- /dev/null +++ b/src/utils/rasterHeaderDimensions.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import sharp from "sharp"; +import { rasterHeaderDimensions } from "./rasterHeaderDimensions"; +import { readUpToBytes } from "./remoteImageMetadata"; + +test("reads layout dimensions without decoding PNG, JPEG, GIF or WebP", async () => { + for (const format of ["png", "jpeg", "gif", "webp"] as const) { + const source = sharp({ create: { width: 30, height: 20, channels: 3, background: "red" } }); + const bytes = await source[format]().toBuffer(); + assert.deepEqual(rasterHeaderDimensions(bytes), { width: 30, height: 20 }, format); + for (let size = 0; size < Math.min(bytes.length, 80); size++) { + assert.doesNotThrow(() => rasterHeaderDimensions(bytes.subarray(0, size)), `${format}, ${size} bytes`); + } + } + const lossless = await sharp({ create: { width: 80, height: 40, channels: 3, background: "blue" } }).webp({ lossless: true }).toBuffer(); + assert.deepEqual(rasterHeaderDimensions(lossless), { width: 80, height: 40 }); + const extended = Buffer.alloc(30); + extended.write("RIFF", 0); + extended.write("WEBPVP8X", 8); + extended.writeUInt32LE(10, 16); + extended.writeUIntLE(99, 24, 3); + extended.writeUIntLE(69, 27, 3); + assert.deepEqual(rasterHeaderDimensions(extended), { width: 100, height: 70 }); +}); + +test("rejects zero, oversized and malformed dimension headers", () => { + assert.equal(rasterHeaderDimensions(Buffer.from("not an image")), null); + const png = Buffer.from("89504e470d0a1a0a0000000d494844520000000000000000", "hex"); + assert.equal(rasterHeaderDimensions(png), null); + png.writeUInt32BE(100_000, 16); + png.writeUInt32BE(100_000, 20); + assert.equal(rasterHeaderDimensions(png), null); + assert.equal(rasterHeaderDimensions(Buffer.from("ffd8ffe00000", "hex")), null); + assert.equal(rasterHeaderDimensions(Buffer.from("ffd8ffe0ffff", "hex")), null); +}); + +test("bounds retained bytes even when the remote stream yields an oversized chunk", async () => { + let cancelled = false; + const stream = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(1_000_000)); }, + cancel() { cancelled = true; }, + }); + assert.equal((await readUpToBytes(new Response(stream), 450_000))?.length, 450_000); + assert.equal(cancelled, true); +}); diff --git a/src/utils/rasterHeaderDimensions.ts b/src/utils/rasterHeaderDimensions.ts new file mode 100644 index 00000000..69d84aa3 --- /dev/null +++ b/src/utils/rasterHeaderDimensions.ts @@ -0,0 +1,51 @@ +import { mediaMimeFromHeader } from "./mediaSignature"; + +// Layout hints only. A readable header does not validate or approve a file. +export function rasterHeaderDimensions(bytes: Buffer): { width: number; height: number } | null { + const dimensions = (width: number, height: number) => + width > 0 && height > 0 && width * height <= 100_000_000 ? { width, height } : null; + const mime = mediaMimeFromHeader(bytes.subarray(0, 32)); + if (mime === "image/png" && bytes.length >= 24 && bytes.toString("ascii", 12, 16) === "IHDR") { + return dimensions(bytes.readUInt32BE(16), bytes.readUInt32BE(20)); + } + if (mime === "image/gif" && bytes.length >= 10) return dimensions(bytes.readUInt16LE(6), bytes.readUInt16LE(8)); + if (mime === "image/jpeg") { + let offset = 2; + while (offset + 1 < bytes.length) { + if (bytes[offset++] !== 0xff) return null; + while (offset < bytes.length && bytes[offset] === 0xff) offset++; + if (offset >= bytes.length) return null; + const marker = bytes[offset++]; + if (marker === 0xda || marker === 0xd9 || marker === 0x00) return null; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (offset + 2 > bytes.length) return null; + const length = bytes.readUInt16BE(offset); + if (length < 2 || offset + length > bytes.length) return null; + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { + return length >= 8 ? dimensions(bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)) : null; + } + offset += length; + } + } + if (mime === "image/webp") { + let offset = 12; + while (offset + 8 <= bytes.length) { + const kind = bytes.toString("ascii", offset, offset + 4); + const length = bytes.readUInt32LE(offset + 4); + const start = offset + 8; + if (kind === "VP8X" && length >= 10 && start + 10 <= bytes.length) { + return dimensions(bytes.readUIntLE(start + 4, 3) + 1, bytes.readUIntLE(start + 7, 3) + 1); + } + if (kind === "VP8L" && length >= 5 && start + 5 <= bytes.length && bytes[start] === 0x2f) { + const bits = bytes.readUInt32LE(start + 1); + return dimensions((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1); + } + if (kind === "VP8 " && length >= 10 && start + 10 <= bytes.length && bytes.subarray(start + 3, start + 6).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { + return dimensions(bytes.readUInt16LE(start + 6) & 0x3fff, bytes.readUInt16LE(start + 8) & 0x3fff); + } + if (start + length > bytes.length) return null; + offset = start + length + (length % 2); + } + } + return null; +} diff --git a/src/utils/remoteImageMetadata.ts b/src/utils/remoteImageMetadata.ts index ebe96aeb..25d045a3 100644 --- a/src/utils/remoteImageMetadata.ts +++ b/src/utils/remoteImageMetadata.ts @@ -1,7 +1,5 @@ import consola from "consola"; -import sharp from "sharp"; - -import { MAX_INPUT_PIXELS } from "./imageValidation"; +import { rasterHeaderDimensions } from "./rasterHeaderDimensions"; import { fetchFollowingSafely, type FetchGuard } from "./safePreviewFetch"; export type RemoteImageMetadata = { @@ -25,7 +23,7 @@ setInterval(() => { } }, 5 * 60 * 1000).unref(); -async function readUpToBytes(res: Response, maxBytes: number): Promise { +export async function readUpToBytes(res: Response, maxBytes: number): Promise { const reader = res.body?.getReader(); if (!reader) return null; const chunks: Uint8Array[] = []; @@ -35,8 +33,9 @@ async function readUpToBytes(res: Response, maxBytes: number): Promise 0) { - chunks.push(value); - bytesRead += value.length; + const chunk = Uint8Array.from(value.subarray(0, maxBytes - bytesRead)); + chunks.push(chunk); + bytesRead += chunk.length; } } } finally { @@ -68,7 +67,7 @@ export async function fetchRemoteImageMetadata(url: string, guard?: FetchGuard): const buf = await readUpToBytes(res, 450_000); if (buf === null) return empty; - const meta = await sharp(buf, { animated: true, failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }).metadata().catch(() => null); + const meta = rasterHeaderDimensions(buf); const data: RemoteImageMetadata = { url, mime: contentType || null, @@ -89,4 +88,3 @@ export async function fetchRemoteImageMetadata(url: string, guard?: FetchGuard): clearTimeout(timeout); } } - diff --git a/tsconfig.json b/tsconfig.json index d827bc50..15122e39 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -98,5 +98,5 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, /* Tests run through ts-node and are not part of the shipped build. */ - "exclude": ["src/**/*.test.ts"] -} \ No newline at end of file + "exclude": ["src/**/*.test.ts", "src/testSupport/**"] +}