From 9046a131f2ac9a7fce9fb2c466c35cf64bd8ee4c Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 6 Aug 2026 15:40:55 +0000 Subject: [PATCH] feat: add signed bind-backed user icon uploads --- .env.example | 2 + README.md | 26 +++++++ compose.yaml | 18 ++++- deploy/nginx/templates/default.conf.template | 21 ++++++ deploy/scripts/init-secrets.sh | 4 +- node-hook/package.json | 2 +- node-hook/src/auth.mjs | 18 ++++- node-hook/src/config.mjs | 18 +++-- node-hook/src/server.mjs | 59 ++++++++++++++- node-hook/src/upload-store.mjs | 63 ++++++++++++++++ node-hook/test/server.test.mjs | 78 +++++++++++++++++++- node-hook/test/upload-store.test.mjs | 39 ++++++++++ 12 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 node-hook/src/upload-store.mjs create mode 100644 node-hook/test/upload-store.test.mjs diff --git a/.env.example b/.env.example index 64f662a..94f16a5 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,5 @@ GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_secret IMAGE_SYNC_CORE_SECRET_FILE=./secrets/image_sync_core_secret IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret +IMAGE_UPLOAD_CORE_SECRET_FILE=./secrets/image_upload_core_secret +IMAGE_UPLOAD_CORE2026_SECRET_FILE=./secrets/image_upload_core2026_secret diff --git a/README.md b/README.md index 8bc2e2b..e8aca49 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,32 @@ Never give either caller `image_admin_secret`, which also authorizes explicit branch changes. This fallback handles webhook delivery outages; if the image service itself is stopped, restore it and run the caller command again. +### Short-lived user-icon uploads + +Core and Core2026 can store validated account icons in this repository through +`PUT /v1/uploads/user-icons//.`. Each game +server validates the authenticated user and image first, then sends the raw +image body with `X-Image-Client`, `X-Image-Expires`, `X-Image-Request-Id`, and +`X-Image-Signature` headers. + +The signature is HMAC-SHA256 over +`expires.requestId.pathname.contentType.sha256(body)`. Expiry may be at most +five minutes in the future, so a grant cannot be reused for another path, +content type, body, or later upload. The service also checks the image magic, +caller scope, and request replay before it writes one immutable file below the +host bind directory `runtime-data/uploads`. User uploads are deliberately not +added to Git; Nginx exposes that bind read-only at `/icons/users/`. + +Create separate upload secrets with `deploy/scripts/init-secrets.sh`. Mount only +the matching `image_upload_core_secret` or `image_upload_core2026_secret` on the +game server. The shared secrets stay server-side in Docker secrets; they are +not returned to browsers or forwarded to Cloudflare. + +Run `deploy/scripts/init-secrets.sh` before the first Compose start so +`runtime-data/uploads` exists with permissions that allow the hook container to +write and the Nginx container to read. Back up this directory independently of +the Git repository when moving servers. + Legacy HTTP mutation is disabled by default. An emergency PHP rollback must first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel in the legacy checkout before restoring its Caddy/Gitea route. Remove the diff --git a/compose.yaml b/compose.yaml index 644a20c..4289b39 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ services: image-hook: build: context: ./node-hook - image: sam-image-hook:1.1.0 + image: sam-image-hook:1.2.0 restart: unless-stopped user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}" read_only: true @@ -18,9 +18,13 @@ services: IMAGE_ALLOWED_BRANCHES: ${IMAGE_ALLOWED_BRANCHES:-master} IMAGE_PUBLIC_BASES: ${IMAGE_PUBLIC_BASES:-https://sam.hided.net/image,https://sam-image.hided.net} IMAGE_STATE_PATH: /var/lib/image-hook/state.json + IMAGE_UPLOAD_ROOT: /var/lib/image-hook/uploads + IMAGE_UPLOAD_STATE_PATH: /var/lib/image-hook/upload-state.json GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_secret IMAGE_SYNC_CLIENT_SECRET_FILES: core=/run/secrets/image_sync_core_secret,core2026=/run/secrets/image_sync_core2026_secret + IMAGE_UPLOAD_CLIENT_SECRET_FILES: core=/run/secrets/image_upload_core_secret,core2026=/run/secrets/image_upload_core2026_secret + MAX_UPLOAD_BYTES: "51200" volumes: - type: bind source: ${IMAGE_REPOSITORY_PATH:-.} @@ -31,6 +35,8 @@ services: - image_admin_secret - image_sync_core_secret - image_sync_core2026_secret + - image_upload_core_secret + - image_upload_core2026_secret tmpfs: - /tmp:size=16m,mode=1777 cap_drop: [ALL] @@ -50,7 +56,7 @@ services: image-web: build: context: ./deploy/nginx - image: sam-image-web:1.1.0 + image: sam-image-web:1.2.0 restart: unless-stopped depends_on: image-hook: @@ -65,6 +71,10 @@ services: source: ${IMAGE_REPOSITORY_PATH:-.} target: /srv/image read_only: true + - type: bind + source: ./runtime-data/uploads + target: /srv/user-icons + read_only: true tmpfs: - /tmp:size=8m,mode=1777 cap_drop: [ALL] @@ -95,3 +105,7 @@ secrets: file: ${IMAGE_SYNC_CORE_SECRET_FILE:-./secrets/image_sync_core_secret} image_sync_core2026_secret: file: ${IMAGE_SYNC_CORE2026_SECRET_FILE:-./secrets/image_sync_core2026_secret} + image_upload_core_secret: + file: ${IMAGE_UPLOAD_CORE_SECRET_FILE:-./secrets/image_upload_core_secret} + image_upload_core2026_secret: + file: ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:-./secrets/image_upload_core2026_secret} diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template index acbea67..c13e4ec 100644 --- a/deploy/nginx/templates/default.conf.template +++ b/deploy/nginx/templates/default.conf.template @@ -69,6 +69,18 @@ http { proxy_request_buffering on; } + location ^~ /v1/uploads/user-icons/ { + limit_except PUT { deny all; } + client_max_body_size 50k; + proxy_pass http://image-hook:8081; + proxy_set_header Host $host; + proxy_set_header X-Image-Client $http_x_image_client; + proxy_set_header X-Image-Expires $http_x_image_expires; + proxy_set_header X-Image-Request-Id $http_x_image_request_id; + proxy_set_header X-Image-Signature $http_x_image_signature; + proxy_request_buffering on; + } + location ^~ /v1/admin/ { return 404; } location = /image { return 404; } location = /image/ { return 404; } @@ -80,6 +92,15 @@ http { add_header X-Content-Type-Options nosniff always; } + location ^~ /icons/users/ { + alias /srv/user-icons/; + etag on; + expires 1y; + add_header Cache-Control "public, immutable" always; + add_header Access-Control-Allow-Origin "*" always; + add_header X-Content-Type-Options nosniff always; + } + location ^~ /icons/ { try_files $uri =404; add_header Access-Control-Allow-Origin "*" always; diff --git a/deploy/scripts/init-secrets.sh b/deploy/scripts/init-secrets.sh index 91fbbf6..f19c932 100755 --- a/deploy/scripts/init-secrets.sh +++ b/deploy/scripts/init-secrets.sh @@ -9,7 +9,8 @@ state_dir="$repository_dir/runtime-data" umask 077 mkdir -p "$secret_dir" mkdir -p "$state_dir" -for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image_sync_core2026_secret; do +mkdir -p "$state_dir/uploads" +for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image_sync_core2026_secret image_upload_core_secret image_upload_core2026_secret; do path="$secret_dir/$name" if [ ! -e "$path" ]; then openssl rand -hex 32 > "$path" @@ -17,5 +18,6 @@ for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image chmod 600 "$path" done chmod 700 "$state_dir" +chmod 755 "$state_dir/uploads" echo "Secret files are ready in $secret_dir (values not printed)." diff --git a/node-hook/package.json b/node-hook/package.json index 1f9f673..e57491b 100644 --- a/node-hook/package.json +++ b/node-hook/package.json @@ -1,6 +1,6 @@ { "name": "sam-image-hook", - "version": "1.1.0", + "version": "1.2.0", "private": true, "type": "module", "engines": { diff --git a/node-hook/src/auth.mjs b/node-hook/src/auth.mjs index b20be01..2442937 100644 --- a/node-hook/src/auth.mjs +++ b/node-hook/src/auth.mjs @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; export function hmacHex(secret, body) { return createHmac('sha256', secret).update(body).digest('hex'); @@ -37,3 +37,19 @@ export function verifyAdminSignature({ secret, timestamp, requestId, body, suppl body, ]), supplied); } + +export function uploadSignature(secret, { expires, requestId, pathname, contentType, body }) { + const digest = createHash('sha256').update(body).digest('hex'); + return hmacHex(secret, `${expires}.${requestId}.${pathname}.${contentType}.${digest}`); +} + +export function verifyUploadSignature({ secret, expires, requestId, pathname, contentType, body, supplied, now = Date.now() }) { + if (!/^\d{10}$/.test(String(expires ?? '')) || !/^[A-Za-z0-9._:-]{8,128}$/.test(requestId ?? '')) { + return false; + } + const expiresAt = Number(expires) * 1000; + if (!Number.isFinite(expiresAt) || expiresAt < now || expiresAt > now + 5 * 60 * 1000) { + return false; + } + return verifyHexHmac(secret, `${expires}.${requestId}.${pathname}.${contentType}.${createHash('sha256').update(body).digest('hex')}`, supplied); +} diff --git a/node-hook/src/config.mjs b/node-hook/src/config.mjs index a40dae0..53e60fa 100644 --- a/node-hook/src/config.mjs +++ b/node-hook/src/config.mjs @@ -18,13 +18,13 @@ function secret(name, fileName) { return readFileSync(path, 'utf8').trim(); } -function syncClientSecrets() { - const entries = text('IMAGE_SYNC_CLIENT_SECRET_FILES', '') +function clientSecrets(variableName) { + const entries = text(variableName, '') .split(',') .map((entry) => entry.trim()) .filter(Boolean); if (entries.length === 0) { - throw new Error('IMAGE_SYNC_CLIENT_SECRET_FILES is required'); + throw new Error(`${variableName} is required`); } const result = Object.create(null); @@ -33,14 +33,14 @@ function syncClientSecrets() { const client = entry.slice(0, separator); const path = entry.slice(separator + 1); if (separator < 1 || !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) || !path) { - throw new Error(`Invalid image sync client entry: ${entry}`); + throw new Error(`Invalid ${variableName} client entry: ${entry}`); } const value = readFileSync(path, 'utf8').trim(); if (value.length < 32) { - throw new Error(`Image sync secret for ${client} must be at least 32 characters`); + throw new Error(`${variableName} secret for ${client} must be at least 32 characters`); } if (result[client]) { - throw new Error(`Duplicate image sync client: ${client}`); + throw new Error(`Duplicate ${variableName} client: ${client}`); } result[client] = value; } @@ -73,7 +73,11 @@ export function loadConfig() { .filter(Boolean), webhookSecret, adminSecret, - syncClientSecrets: syncClientSecrets(), + syncClientSecrets: clientSecrets('IMAGE_SYNC_CLIENT_SECRET_FILES'), + uploadClientSecrets: clientSecrets('IMAGE_UPLOAD_CLIENT_SECRET_FILES'), maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')), + maxUploadBytes: Number(text('MAX_UPLOAD_BYTES', '51200')), + uploadRoot: text('IMAGE_UPLOAD_ROOT', '/var/lib/image-hook/uploads'), + uploadStatePath: text('IMAGE_UPLOAD_STATE_PATH', '/var/lib/image-hook/upload-state.json'), }; } diff --git a/node-hook/src/server.mjs b/node-hook/src/server.mjs index 512c8eb..51e8552 100644 --- a/node-hook/src/server.mjs +++ b/node-hook/src/server.mjs @@ -1,8 +1,9 @@ import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { loadConfig } from './config.mjs'; -import { verifyAdminSignature, verifyHexHmac } from './auth.mjs'; +import { verifyAdminSignature, verifyHexHmac, verifyUploadSignature } from './auth.mjs'; import { DeploymentError, GitService } from './git-service.mjs'; +import { UploadStore } from './upload-store.mjs'; function json(response, status, value) { const body = JSON.stringify(value); @@ -35,9 +36,22 @@ function parseJson(body) { } } +function hasImageSignature(body, extension) { + if (extension === 'png') return body.length >= 8 && body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')); + if (extension === 'jpg') return body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff; + if (extension === 'gif') return body.length >= 6 && ['GIF87a', 'GIF89a'].includes(body.subarray(0, 6).toString('ascii')); + if (extension === 'webp') return body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF' + && body.subarray(8, 12).toString('ascii') === 'WEBP'; + if (extension === 'avif') return body.length >= 16 && body.subarray(4, 8).toString('ascii') === 'ftyp' + && (body.subarray(8, 64).includes(Buffer.from('avif')) || body.subarray(8, 64).includes(Buffer.from('avis'))); + return false; +} + export async function createApp(config = loadConfig(), dependencies = {}) { const service = dependencies.service ?? new GitService(config); + const uploadStore = dependencies.uploadStore ?? new UploadStore(config); await service.initialize(); + await uploadStore.initialize(); const server = createServer(async (request, response) => { const url = new URL(request.url, 'http://image-hook'); @@ -135,6 +149,47 @@ export async function createApp(config = loadConfig(), dependencies = {}) { }); return json(response, 200, { ok: true, ...result }); } + if (request.method === 'PUT' && url.pathname.startsWith('/v1/uploads/user-icons/')) { + const client = request.headers['x-image-client']; + const expires = request.headers['x-image-expires']; + const requestId = request.headers['x-image-request-id']; + const contentType = request.headers['content-type']?.toLowerCase() ?? ''; + const knownClient = typeof client === 'string' && Object.hasOwn(config.uploadClientSecrets, client); + const match = url.pathname.match(/^\/v1\/uploads\/user-icons\/([a-z0-9][a-z0-9_-]{1,31})\/([a-f0-9]{32})\.(avif|webp|jpg|png|gif)$/); + if (!match || match[1] !== client) { + throw new DeploymentError('Invalid upload path', 400); + } + const mimeByExtension = { + avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif', + }; + if (contentType !== mimeByExtension[match[3]]) { + throw new DeploymentError('Content-Type does not match upload path', 415); + } + const body = await readBody(request, config.maxUploadBytes); + const signatureValid = verifyUploadSignature({ + secret: knownClient ? config.uploadClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'), + expires, + requestId, + pathname: url.pathname, + contentType, + body, + supplied: request.headers['x-image-signature'], + }); + if (!knownClient || !signatureValid) { + return json(response, 401, { ok: false, reason: 'invalid or expired upload grant' }); + } + if (!hasImageSignature(body, match[3])) { + throw new DeploymentError('Body is not the declared image format', 400); + } + const result = await uploadStore.store({ + requestKey: `${client}:${requestId}`, + client, + filename: `${match[2]}.${match[3]}`, + body, + }); + const urls = config.publicBases.map((base) => `${base}/${result.path}`); + return json(response, 201, { ok: true, ...result, urls }); + } return json(response, 404, { ok: false, reason: 'not found' }); } catch (error) { await service.recordError(error).catch(() => undefined); @@ -146,7 +201,7 @@ export async function createApp(config = loadConfig(), dependencies = {}) { } }); - return { server, service }; + return { server, service, uploadStore }; } if (process.argv[1] === new URL(import.meta.url).pathname) { diff --git a/node-hook/src/upload-store.mjs b/node-hook/src/upload-store.mjs new file mode 100644 index 0000000..6bef08e --- /dev/null +++ b/node-hook/src/upload-store.mjs @@ -0,0 +1,63 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { DeploymentError } from './git-service.mjs'; + +export class UploadStore { + constructor(config) { + this.root = config.uploadRoot; + this.statePath = config.uploadStatePath; + this.queue = Promise.resolve(); + this.uploads = []; + } + + async initialize() { + await mkdir(this.root, { recursive: true }); + await mkdir(dirname(this.statePath), { recursive: true }); + try { + const saved = JSON.parse(await readFile(this.statePath, 'utf8')); + this.uploads = Array.isArray(saved.uploads) ? saved.uploads : []; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + + store({ requestKey, client, filename, body }) { + const operation = this.queue.then(async () => { + if (!/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) + || !/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(filename)) { + throw new DeploymentError('Invalid upload path', 400); + } + const relativePath = `${client}/${filename}`; + const path = `icons/users/${relativePath}`; + const digest = createHash('sha256').update(body).digest('hex'); + const previous = this.uploads.find((upload) => upload.key === requestKey); + if (previous) { + if (previous.path !== path || previous.digest !== digest) { + throw new DeploymentError('Upload request ID was already used', 409); + } + return { duplicate: true, path: previous.path }; + } + const destination = join(this.root, relativePath); + await mkdir(dirname(destination), { recursive: true }); + try { + await writeFile(destination, body, { flag: 'wx', mode: 0o644 }); + } catch (error) { + if (error.code !== 'EEXIST' || !(await readFile(destination)).equals(body)) { + throw new DeploymentError('Upload path already exists', 409); + } + } + this.uploads = [...this.uploads.slice(-999), { key: requestKey, path, digest }]; + await this.#save(); + return { duplicate: false, path }; + }); + this.queue = operation.catch(() => undefined); + return operation; + } + + async #save() { + const temporary = `${this.statePath}.tmp-${process.pid}`; + await writeFile(temporary, JSON.stringify({ uploads: this.uploads }, null, 2), { mode: 0o600 }); + await rename(temporary, this.statePath); + } +} diff --git a/node-hook/test/server.test.mjs b/node-hook/test/server.test.mjs index c8f0fbb..a0e2f1c 100644 --- a/node-hook/test/server.test.mjs +++ b/node-hook/test/server.test.mjs @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import { once } from 'node:events'; import test from 'node:test'; -import { adminSignature } from '../src/auth.mjs'; +import { adminSignature, uploadSignature } from '../src/auth.mjs'; import { createApp } from '../src/server.mjs'; +const noUploadStore = { async initialize() {}, async store() { throw new Error('must not upload'); } }; + test('sync endpoint authenticates a scoped caller and passes only an optional commit', async (t) => { const calls = []; const service = { @@ -18,7 +20,7 @@ test('sync endpoint authenticates a scoped caller and passes only an optional co const { server } = await createApp({ maxBodyBytes: 4096, syncClientSecrets: { core: secret }, - }, { service }); + }, { service, uploadStore: noUploadStore }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); t.after(() => server.close()); @@ -42,6 +44,73 @@ test('sync endpoint authenticates a scoped caller and passes only an optional co assert.deepEqual(calls, [{ requestKey: `core:${requestId}`, expectedCommit: 'a'.repeat(40) }]); }); +test('upload endpoint accepts a short-lived body-bound grant and rejects replay or tampering', async (t) => { + const service = { + async initialize() {}, + async recordError() {}, + }; + const calls = []; + const uploadStore = { + async initialize() {}, + async store(value) { + calls.push(value); + return { duplicate: false, path: `icons/users/${value.client}/${value.filename}` }; + }, + }; + const secret = 'u'.repeat(32); + const config = { + maxBodyBytes: 4096, + maxUploadBytes: 51200, + syncClientSecrets: { core: 's'.repeat(32) }, + uploadClientSecrets: { core2026: secret }, + publicBases: ['https://sam-image.hided.net', 'https://sam.hided.net/image'], + }; + const { server } = await createApp(config, { service, uploadStore }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + const address = server.address(); + const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`; + const body = Buffer.from('89504e470d0a1a0a00000000', 'hex'); + const expires = String(Math.floor(Date.now() / 1000) + 60); + const requestId = 'upload-request-1234'; + const signature = uploadSignature(secret, { expires, requestId, pathname, contentType: 'image/png', body }); + const headers = { + 'content-type': 'image/png', + 'x-image-client': 'core2026', + 'x-image-expires': expires, + 'x-image-request-id': requestId, + 'x-image-signature': signature, + }; + const accepted = await fetch(`http://127.0.0.1:${address.port}${pathname}`, { method: 'PUT', headers, body }); + assert.equal(accepted.status, 201); + assert.deepEqual(calls[0], { + requestKey: `core2026:${requestId}`, + client: 'core2026', + filename: `${'a'.repeat(32)}.png`, + body, + }); + assert.deepEqual((await accepted.json()).urls, [ + `https://sam-image.hided.net/icons/users/core2026/${'a'.repeat(32)}.png`, + `https://sam.hided.net/image/icons/users/core2026/${'a'.repeat(32)}.png`, + ]); + + const tampered = await fetch(`http://127.0.0.1:${address.port}${pathname}`, { + method: 'PUT', headers, body: Buffer.from('89504e470d0a1a0affffffff', 'hex'), + }); + assert.equal(tampered.status, 401); + const expired = '1000000000'; + const expiredHeaders = { + ...headers, + 'x-image-expires': expired, + 'x-image-signature': uploadSignature(secret, { expires: expired, requestId, pathname, contentType: 'image/png', body }), + }; + const expiredResponse = await fetch(`http://127.0.0.1:${address.port}${pathname}`, { + method: 'PUT', headers: expiredHeaders, body, + }); + assert.equal(expiredResponse.status, 401); +}); + test('sync endpoint rejects unknown callers and body fields outside the sync contract', async (t) => { const service = { async initialize() {}, @@ -49,7 +118,10 @@ test('sync endpoint rejects unknown callers and body fields outside the sync con async recordError() {}, }; const secret = 's'.repeat(32); - const { server } = await createApp({ maxBodyBytes: 4096, syncClientSecrets: { core: secret } }, { service }); + const { server } = await createApp( + { maxBodyBytes: 4096, syncClientSecrets: { core: secret } }, + { service, uploadStore: noUploadStore }, + ); server.listen(0, '127.0.0.1'); await once(server, 'listening'); t.after(() => server.close()); diff --git a/node-hook/test/upload-store.test.mjs b/node-hook/test/upload-store.test.mjs new file mode 100644 index 0000000..fd71b8b --- /dev/null +++ b/node-hook/test/upload-store.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import test from 'node:test'; +import { UploadStore } from '../src/upload-store.mjs'; + +test('stores uploads only in the bind directory and persists replay state', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'image-upload-store-')); + t.after(() => rm(root, { recursive: true, force: true })); + const config = { uploadRoot: join(root, 'uploads'), uploadStatePath: join(root, 'upload-state.json') }; + const body = Buffer.from('immutable image bytes'); + const filename = `${'a'.repeat(32)}.png`; + const store = new UploadStore(config); + await store.initialize(); + const first = await store.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body }); + assert.deepEqual(first, { duplicate: false, path: `icons/users/core2026/${filename}` }); + assert.equal(await readFile(join(root, 'uploads', 'core2026', filename), 'utf8'), 'immutable image bytes'); + + const restarted = new UploadStore(config); + await restarted.initialize(); + assert.deepEqual( + await restarted.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body }), + { duplicate: true, path: `icons/users/core2026/${filename}` }, + ); + await assert.rejects( + restarted.store({ + requestKey: 'core2026:request-1', + client: 'core2026', + filename: `${'b'.repeat(32)}.png`, + body, + }), + /already used/, + ); + await assert.rejects( + restarted.store({ requestKey: 'core2026:request-2', client: '../escape', filename, body }), + /Invalid upload path/, + ); +});