Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*

node_modules
.worker-tests/
dist
dist-selfhosted
dist-ssr
Expand Down Expand Up @@ -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/
data/
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down
3 changes: 2 additions & 1 deletion src/import/discord/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
11 changes: 3 additions & 8 deletions src/jobs/emojiQueueWorker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import consola from "consola";
import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";

import { deleteObject, getObjectAsBuffer, putObject } from "../storage";
Expand Down Expand Up @@ -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})`);

Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -118,4 +114,3 @@ export function startEmojiQueueWorker(): void {
tick().catch((e) => consola.warn("emoji queue tick failed", e));
}, pollMs);
}

13 changes: 13 additions & 0 deletions src/routes/bannerUpload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/routes/emojiBttvImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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 });
Expand Down
8 changes: 4 additions & 4 deletions src/routes/emojis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 34 additions & 1 deletion src/routes/groupIconUpload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +32,7 @@ let dir: string;
let server: Server;
let base = "";
let host = "";
let stopWorker: () => Promise<void>;

interface Member { serverUserId: string; grytUserId: string; nickname: string }

Expand Down Expand Up @@ -64,17 +68,46 @@ 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<void>((resolve) => server.once("listening", () => resolve()));
const { port } = server.address() as AddressInfo;
host = `127.0.0.1:${port}`;
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<void>((resolve) => server.close(() => resolve()));
delete process.env.DATA_DIR;
delete process.env.S3_BUCKET;
Expand Down
4 changes: 4 additions & 0 deletions src/routes/routeJsonLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */

Expand All @@ -27,6 +28,7 @@ let dir: string;
let server: Server;
let base = "";
let token = "";
let stopWorker: () => Promise<void>;

async function call(method: string, path: string, body: string | FormData) {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
Expand Down Expand Up @@ -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");
Expand All @@ -80,6 +83,7 @@ before(async () => {
});

after(async () => {
await stopWorker?.();
await new Promise<void>((resolve) => server.close(() => resolve()));
delete process.env.DATA_DIR;
delete process.env.S3_BUCKET;
Expand Down
81 changes: 11 additions & 70 deletions src/routes/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading