feat: add signed bind-backed user icon uploads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<client>/<random-32-hex>.<extension>`. 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
|
||||
|
||||
+16
-2
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sam-image-hook",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
+17
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user