From 7ae7295e1e594b0fc840839393b3ef5e0098ae53 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 16 Aug 2026 09:21:15 +0200 Subject: [PATCH] feat(storage): multer upload options factory --- .../nest/storage/storage-upload.factory.ts | 68 ++++++++ .../storage/storage-upload.factory.test.ts | 162 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 server/src/nest/storage/storage-upload.factory.ts create mode 100644 server/tests/unit/nest/storage/storage-upload.factory.test.ts diff --git a/server/src/nest/storage/storage-upload.factory.ts b/server/src/nest/storage/storage-upload.factory.ts new file mode 100644 index 000000000..4ada1b867 --- /dev/null +++ b/server/src/nest/storage/storage-upload.factory.ts @@ -0,0 +1,68 @@ +/** + * Multer options factory for storage-backed uploads. + * + * Every disk-backed upload route consumes this through its module's + * MulterModule.registerAsync DI factory: multer spools the request body into + * the category backend's own spool dir (same volume as the destination), and + * the handler commits with storage.put(category, file.filename, + * { tmpPath: file.path }) — an atomic same-volume rename. + * + * The factory is deliberately ignorant of allowlists and per-route quirks: + * fileFilter closures are caller-owned and passed through untouched. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { diskStorage } from 'multer'; +import type { Options } from 'multer'; +import type { Request } from 'express'; +import { StorageService } from './storage.service'; +import type { StorageCategory } from './storage.types'; + +export interface StorageUploadOptions { + /** + * Constant for single-category modules; a per-request/per-file resolver for + * modules whose routes feed different categories (collections: fieldname + * 'image' → 'places', else 'covers'). + */ + category: StorageCategory | ((req: Request, file: Express.Multer.File) => StorageCategory); + /** → limits.fileSize */ + maxSize: number; + /** Caller-built closure, passed through untouched. */ + fileFilter?: Options['fileFilter']; + /** + * Returns the full final filename. Default: `${randomUUID()}${extname}` — + * extension case-preserved, no fallback for extensionless names (parity with + * the pre-factory call sites). + */ + filename?: (req: Request, file: Express.Multer.File) => string; + /** Parity-phase opt-in (files + collab pass 'utf8'). */ + defParamCharset?: 'utf8'; +} + +export function buildStorageUploadOptions(storage: StorageService, opts: StorageUploadOptions) { + const resolveCategory = + typeof opts.category === 'function' ? opts.category : () => opts.category as StorageCategory; + const makeName = + opts.filename ?? + ((_req: Request, file: Express.Multer.File) => `${randomUUID()}${path.extname(file.originalname)}`); + return { + storage: diskStorage({ + // Resolved per request on purpose (registry no-cached-refs rule): a + // reload() that remaps the category must be picked up by the next upload. + destination: (req, file, cb) => { + try { + const dir = storage.spoolDirFor(resolveCategory(req, file)); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + } catch (err) { + cb(err as Error, ''); + } + }, + filename: (req, file, cb) => cb(null, makeName(req, file)), + }), + limits: { fileSize: opts.maxSize }, + ...(opts.fileFilter ? { fileFilter: opts.fileFilter } : {}), + ...(opts.defParamCharset ? { defParamCharset: opts.defParamCharset } : {}), + }; +} diff --git a/server/tests/unit/nest/storage/storage-upload.factory.test.ts b/server/tests/unit/nest/storage/storage-upload.factory.test.ts new file mode 100644 index 000000000..29e5fbf55 --- /dev/null +++ b/server/tests/unit/nest/storage/storage-upload.factory.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for buildStorageUploadOptions — the multer options factory every + * upload call site consumes via MulterModule.registerAsync. + * + * STORAGE-UPLOAD-001 … 010. + * + * The diskStorage engine exposes its destination/filename callbacks as + * getDestination/getFilename on the engine instance (multer/storage/disk.js), + * so the tests drive them directly instead of streaming a real request. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Request } from 'express'; +import { buildStorageUploadOptions } from '../../../../src/nest/storage/storage-upload.factory'; +import type { StorageService } from '../../../../src/nest/storage/storage.service'; +import type { StorageCategory } from '../../../../src/nest/storage/storage.types'; + +interface DiskEngine { + getDestination: (req: Request, file: Express.Multer.File, cb: (err: Error | null, dir: string) => void) => void; + getFilename: (req: Request, file: Express.Multer.File, cb: (err: Error | null, name: string) => void) => void; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function fakeFile(overrides: Partial = {}): Express.Multer.File { + return { fieldname: 'file', originalname: 'doc.pdf', ...overrides } as Express.Multer.File; +} + +const req = {} as Request; + +function destination(engine: DiskEngine, file: Express.Multer.File): Promise { + return new Promise((resolve, reject) => + engine.getDestination(req, file, (err, dir) => (err ? reject(err) : resolve(dir))), + ); +} + +function filename(engine: DiskEngine, file: Express.Multer.File): Promise { + return new Promise((resolve, reject) => + engine.getFilename(req, file, (err, name) => (err ? reject(err) : resolve(name))), + ); +} + +describe('buildStorageUploadOptions', () => { + let dirA: string; + let dirB: string; + + beforeAll(() => { + dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-upload-factory-a-')); + dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-upload-factory-b-')); + }); + + afterAll(() => { + fs.rmSync(dirA, { recursive: true, force: true }); + fs.rmSync(dirB, { recursive: true, force: true }); + }); + + it('STORAGE-UPLOAD-001 — destination resolves spoolDirFor per request, never cached', async () => { + const spoolDirFor = vi.fn(() => dirA); + const storage = { spoolDirFor } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + expect(await destination(engine, fakeFile())).toBe(dirA); + spoolDirFor.mockReturnValue(dirB); + expect(await destination(engine, fakeFile())).toBe(dirB); + expect(spoolDirFor).toHaveBeenCalledTimes(2); + expect(spoolDirFor).toHaveBeenCalledWith('files'); + }); + + it('STORAGE-UPLOAD-002 — destination creates the spool dir if missing', async () => { + const missing = path.join(dirA, 'not-yet-created'); + const storage = { spoolDirFor: () => missing } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + expect(await destination(engine, fakeFile())).toBe(missing); + expect(fs.existsSync(missing)).toBe(true); + }); + + it('STORAGE-UPLOAD-003 — category resolver receives (req, file) and routes per file', async () => { + const spoolDirFor = vi.fn((cat: StorageCategory) => (cat === 'places' ? dirA : dirB)); + const storage = { spoolDirFor } as unknown as StorageService; + const category = vi.fn((_r: Request, file: Express.Multer.File): StorageCategory => + file.fieldname === 'image' ? 'places' : 'covers', + ); + const opts = buildStorageUploadOptions(storage, { category, maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + expect(await destination(engine, fakeFile({ fieldname: 'image' }))).toBe(dirA); + expect(await destination(engine, fakeFile({ fieldname: 'cover' }))).toBe(dirB); + expect(spoolDirFor).toHaveBeenNthCalledWith(1, 'places'); + expect(spoolDirFor).toHaveBeenNthCalledWith(2, 'covers'); + expect(category).toHaveBeenCalledWith(req, expect.objectContaining({ fieldname: 'cover' })); + }); + + it('STORAGE-UPLOAD-004 — destination surfaces spoolDirFor errors through the callback', async () => { + const boom = new Error('registry not initialized'); + const storage = { spoolDirFor: () => { throw boom; } } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + await expect(destination(engine, fakeFile())).rejects.toBe(boom); + }); + + it('STORAGE-UPLOAD-005 — default filename is a v4 uuid with the case-preserved extension', async () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + const upper = await filename(engine, fakeFile({ originalname: 'Photo.JPG' })); + expect(upper).toMatch(/\.JPG$/); + expect(upper.slice(0, -4)).toMatch(UUID_RE); + }); + + it('STORAGE-UPLOAD-006 — default filename has no fallback extension for extensionless names', async () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + const engine = opts.storage as unknown as DiskEngine; + + expect(await filename(engine, fakeFile({ originalname: 'noext' }))).toMatch(UUID_RE); + }); + + it('STORAGE-UPLOAD-007 — a custom filename hook is used verbatim', async () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { + category: 'journey', + maxSize: 10, + filename: (_r, file) => (file.fieldname === 'poster' ? 'forced.jpg' : 'other.bin'), + }); + const engine = opts.storage as unknown as DiskEngine; + + expect(await filename(engine, fakeFile({ fieldname: 'poster', originalname: 'x.html' }))).toBe('forced.jpg'); + expect(await filename(engine, fakeFile({ fieldname: 'video' }))).toBe('other.bin'); + }); + + it('STORAGE-UPLOAD-008 — maxSize becomes limits.fileSize', () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const opts = buildStorageUploadOptions(storage, { category: 'files', maxSize: 123456 }); + expect(opts.limits).toEqual({ fileSize: 123456 }); + }); + + it('STORAGE-UPLOAD-009 — fileFilter passes through untouched, and is absent when not given', () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const filter = vi.fn(); + const withFilter = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10, fileFilter: filter }); + expect(withFilter.fileFilter).toBe(filter); + + const without = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + expect('fileFilter' in without).toBe(false); + }); + + it('STORAGE-UPLOAD-010 — defParamCharset is absent unless passed, utf8 when passed', () => { + const storage = { spoolDirFor: () => dirA } as unknown as StorageService; + const without = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10 }); + expect('defParamCharset' in without).toBe(false); + + const withCharset = buildStorageUploadOptions(storage, { category: 'files', maxSize: 10, defParamCharset: 'utf8' }); + expect((withCharset as { defParamCharset?: string }).defParamCharset).toBe('utf8'); + }); +});