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); } }