import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import sharp from "sharp"; import { isAllowedUploadMimeType, uploadRequestSchema, UPLOAD_RULES, type UploadKind, type UploadedFileMetadata, } from "@/lib/validations/upload"; const imageExtension = ".webp"; const audioExtensions: Record = { "audio/aac": ".aac", "audio/flac": ".flac", "audio/mp4": ".m4a", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/webm": ".webm", "audio/x-wav": ".wav", }; const mimeTypesByExtension: Record = { ".aac": "audio/aac", ".flac": "audio/flac", ".m4a": "audio/mp4", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".webm": "audio/webm", ".webp": "image/webp", }; export class UploadError extends Error { statusCode: 400 | 404 | 413 | 500; constructor(message: string, statusCode: 400 | 404 | 413 | 500 = 400) { super(message); this.name = "UploadError"; this.statusCode = statusCode; } } export function getUploadRoot() { return path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), "uploads")); } function getKindDirectory(kind: UploadKind) { return path.join(getUploadRoot(), `${kind}s`); } function getPublicUrl(kind: UploadKind, fileName: string) { return `/api/uploads/${kind}s/${fileName}`; } function validateUpload(kind: UploadKind, file: File) { const parsed = uploadRequestSchema.safeParse({ kind, fileName: file.name, mimeType: file.type, size: file.size, }); if (!parsed.success) { throw new UploadError("Invalid upload metadata."); } const rules = UPLOAD_RULES[kind]; if (!isAllowedUploadMimeType(kind, parsed.data.mimeType)) { throw new UploadError(`Unsupported ${kind} file type.`); } if (parsed.data.size > rules.maxBytes) { throw new UploadError(`The ${kind} file is too large.`, 413); } return parsed.data; } export async function saveUploadedFile(file: File, kind: UploadKind): Promise { const metadata = validateUpload(kind, file); const source = Buffer.from(await file.arrayBuffer()); const id = randomUUID(); if (kind === "image") { const fileName = `${id}${imageExtension}`; const output = await sharp(source) .rotate() .resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true }) .webp({ quality: 82 }) .toBuffer() .catch(() => { throw new UploadError("The image could not be processed."); }); await mkdir(getKindDirectory(kind), { recursive: true }); await writeFile(path.join(getKindDirectory(kind), fileName), output, { flag: "wx" }); return { kind, url: getPublicUrl(kind, fileName), fileName, mimeType: "image/webp", size: output.byteLength, }; } const extension = audioExtensions[metadata.mimeType.toLowerCase()]; if (!extension) { throw new UploadError("Unsupported audio file type."); } const fileName = `${id}${extension}`; await mkdir(getKindDirectory(kind), { recursive: true }); await writeFile(path.join(getKindDirectory(kind), fileName), source, { flag: "wx" }); return { kind, url: getPublicUrl(kind, fileName), fileName, mimeType: metadata.mimeType, size: source.byteLength, }; } export async function readUploadedFile(segments: string[]) { const root = getUploadRoot(); const filePath = path.resolve(root, ...segments); const rootPrefix = `${root}${path.sep}`; if (!filePath.startsWith(rootPrefix)) { throw new UploadError("File not found.", 404); } try { const fileInfo = await stat(filePath); if (!fileInfo.isFile()) { throw new UploadError("File not found.", 404); } const extension = path.extname(filePath).toLowerCase(); return { data: await readFile(filePath), mimeType: mimeTypesByExtension[extension] ?? "application/octet-stream", }; } catch (error) { if (error instanceof UploadError) { throw error; } throw new UploadError("File not found.", 404); } }