import { Controller, Delete, Get, HttpError, HttpStatusCode, Put, type Input, } from '@dunx/http'; import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files'; import { z } from 'zod'; /** * The key is a query parameter because keys contain slashes: `reports/q1.csv` is * one key, not two segments. `Storage` itself rejects traversal - try * `?key=../../etc/passwd`, or `?glob=/etc/*` on the listing. */ const FileKey = z.object({ key: z.string().min(1).max(200) }).meta({ id: 'FileKey', description: 'An object key inside the storage root', }); const WriteFile = z .object({ content: z.string().max(64 * 1024) }) .meta({ id: 'WriteFile', description: 'Text to store under the key' }); const listFiles = { query: z.object({ prefix: z.string().max(200).default(''), glob: z.string().max(200).default('**/*'), }), } as const; const objectKey = { query: FileKey } as const; const writeFile = { query: FileKey, body: WriteFile } as const; /** Injects `Storage`, never `LocalStorage`: swapping disk for bucket is one * `forRoot` call and nothing here changes. */ @Controller('files') export class FilesController { constructor(private readonly storage: Storage) {} @Get('/', listFiles) async list({ query }: Input): Promise<{ root: string; keys: readonly string[]; }> { const keys: string[] = []; try { for await (const entry of this.storage.list({ prefix: query.prefix, glob: query.glob, })) { keys.push(entry.key); } } catch (error) { if (!(error instanceof PathTraversalError)) throw error; throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message); } // The contract cannot promise a root, so narrowing reaches it. const root = this.storage instanceof LocalStorage ? this.storage.root : '(remote)'; return { root, keys: keys.sort() }; } @Get('/object', objectKey) async read({ query }: Input): Promise<{ key: string; size: number; type: string; content: string; }> { const { key } = query; await this.present(key); const stat = await this.storage.stat(key); return { key, size: stat.size, type: stat.type, content: await this.storage.read(key), }; } @Put('/object', writeFile) async write({ body, query, }: Input): Promise<{ key: string; bytes: number }> { const { key } = query; try { return { key, bytes: await this.storage.write(key, body.content) }; } catch (error) { if (!(error instanceof PathTraversalError)) throw error; throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message); } } @Delete('/object', objectKey) async remove({ query, }: Input): Promise<{ deleted: boolean }> { const { key } = query; await this.present(key); await this.storage.delete(key); return { deleted: true }; } /** Nothing signs bytes on a local disk, so this refuses rather than hand back * a URL that cannot work. */ @Get('/presign', objectKey) async presign({ query }: Input): Promise<{ url: string }> { const { key } = query; await this.present(key); try { return { url: this.storage.presign(key) }; } catch (error) { throw new HttpError( HttpStatusCode.NOT_IMPLEMENTED, (error as Error).message, ); } } /** A traversal is a bad request: `Storage` rejects it before any syscall, and * without this it would surface as a 500. */ private async present(key: string): Promise { try { if (await this.storage.exists(key)) return; } catch (error) { if (!(error instanceof PathTraversalError)) throw error; throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message); } throw new HttpError(HttpStatusCode.NOT_FOUND, `No object "${key}"`); } }