import { randomUUID } from "crypto"; import { mkdir, rm, writeFile } from "fs/promises"; import path from "path"; export const MEDIA_UPLOAD_ROOT = path.join(process.cwd(), "public", "uploads", "media"); export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024; const MIME_EXTENSIONS: Record = { "image/x-icon": ".ico", "image/vnd.microsoft.icon": ".ico", "image/gif": ".gif", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/svg+xml": ".svg", "application/pdf": ".pdf", }; export function sanitizeBaseName(value: string) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); } export function getExtensionForMimeType(mimeType: string) { return MIME_EXTENSIONS[mimeType] ?? null; } export function isManagedMediaFilePath(filePath: string | null | undefined) { return typeof filePath === "string" && filePath.startsWith("/uploads/media/"); } export function resolveMediaUploadPath(filePath: string) { if (!isManagedMediaFilePath(filePath)) { throw new Error("Only managed media uploads can be resolved."); } const relativePath = filePath.slice("/uploads/media/".length); if (!relativePath || relativePath.includes("\0")) { throw new Error("Resolved media upload path escapes the upload root."); } const absolutePath = path.resolve(MEDIA_UPLOAD_ROOT, relativePath); // `startsWith(root)` alone would accept a sibling directory such as // `.../uploads/media-evil/...`; require the separator so only true children pass. if (absolutePath !== MEDIA_UPLOAD_ROOT && !absolutePath.startsWith(MEDIA_UPLOAD_ROOT + path.sep)) { throw new Error("Resolved media upload path escapes the upload root."); } if (absolutePath === MEDIA_UPLOAD_ROOT) { throw new Error("Resolved media upload path escapes the upload root."); } return absolutePath; } const MAGIC_SIGNATURES: Record> = { ".png": [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }], ".jpg": [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }], ".gif": [{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] }], ".webp": [ { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] }, ], ".pdf": [{ offset: 0, bytes: [0x25, 0x50, 0x44, 0x46] }], ".ico": [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }], }; const SVG_FORBIDDEN_PATTERN = /]|javascript:|on[a-z]+\s*=|]/i.test(head)); return looksLikeSvg && !SVG_FORBIDDEN_PATTERN.test(buffer.toString("utf8")); } const signatures = MAGIC_SIGNATURES[extension]; if (!signatures) { return false; } return signatures.every(({ offset, bytes }) => bytes.every((byte, index) => buffer[offset + index] === byte), ); } export async function removeManagedMediaFile(filePath: string | null | undefined) { if (!isManagedMediaFilePath(filePath)) { return false; } const managedFilePath = filePath as string; await rm(resolveMediaUploadPath(managedFilePath), { force: true, }); return true; } export async function saveMediaUpload(file: File, folder: string) { if (!file || file.size === 0) { return null; } const extension = getExtensionForMimeType(file.type); if (!extension) { throw new Error("Unsupported file type."); } if (file.size > MAX_MEDIA_FILE_SIZE) { throw new Error("File is too large."); } const buffer = Buffer.from(await file.arrayBuffer()); if (!isMediaContentValid(extension, buffer)) { throw new Error("File content does not match its declared type."); } const safeFolder = sanitizeBaseName(folder) || "misc"; const safeBaseName = sanitizeBaseName(file.name.replace(/\.[^.]+$/, "")) || "asset"; const finalName = `${safeBaseName}-${randomUUID().slice(0, 8)}${extension}`; const targetDir = path.join(MEDIA_UPLOAD_ROOT, safeFolder); const targetPath = path.join(targetDir, finalName); await mkdir(targetDir, { recursive: true }); await writeFile(targetPath, buffer); return { url: `/uploads/media/${safeFolder}/${finalName}`, fileName: finalName, mimeType: file.type, size: file.size, }; }