import { createHash } from "node:crypto"; import { mkdir, writeFile, readFile, unlink, access } from "node:fs/promises"; import { join, extname, resolve } from "node:path"; import { prisma } from "@/lib/prisma"; import { env } from "@/lib/env"; export const FILE_KINDS = ["step", "pdf", "dxf", "svg", "png", "jpg", "other"] as const; export type FileKind = (typeof FILE_KINDS)[number]; const EXT_TO_KIND: Record = { ".step": "step", ".stp": "step", ".pdf": "pdf", ".dxf": "dxf", ".svg": "svg", ".png": "png", ".jpg": "jpg", ".jpeg": "jpg", }; const MAX_SIZE = 100 * 1024 * 1024; // 100 MB per file export function classifyKind(filename: string, mimeType?: string | null): FileKind { const ext = extname(filename).toLowerCase(); if (EXT_TO_KIND[ext]) return EXT_TO_KIND[ext]; if (mimeType?.startsWith("image/")) { if (mimeType.includes("png")) return "png"; if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpg"; if (mimeType.includes("svg")) return "svg"; } if (mimeType === "application/pdf") return "pdf"; return "other"; } function uploadRoot(): string { return resolve(env.UPLOAD_DIR); } export function storagePathFor(sha256: string, kind: FileKind, originalName: string): { relative: string; absolute: string; } { const ext = extname(originalName).toLowerCase() || `.${kind}`; const shard = sha256.slice(0, 2); const relative = join(kind, shard, `${sha256}${ext}`); return { relative, absolute: join(uploadRoot(), relative), }; } export async function fileExistsOnDisk(absolutePath: string): Promise { try { await access(absolutePath); return true; } catch { return false; } } export interface SavedFile { id: string; kind: FileKind; sha256: string; path: string; sizeBytes: number; originalName: string; mimeType: string | null; deduped: boolean; } export async function saveUploadedFile( file: File, uploadedBy: string | null, ): Promise { if (file.size === 0) throw new Error("Empty file"); if (file.size > MAX_SIZE) throw new Error(`File too large (max ${MAX_SIZE / 1024 / 1024}MB)`); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const sha256 = createHash("sha256").update(buffer).digest("hex"); const kind = classifyKind(file.name, file.type || null); const existing = await prisma.fileAsset.findUnique({ where: { sha256 } }); if (existing) { return { id: existing.id, kind: existing.kind as FileKind, sha256: existing.sha256, path: existing.path, sizeBytes: existing.sizeBytes, originalName: existing.originalName, mimeType: existing.mimeType, deduped: true, }; } const { relative, absolute } = storagePathFor(sha256, kind, file.name); await mkdir(join(absolute, ".."), { recursive: true }); await writeFile(absolute, buffer); const asset = await prisma.fileAsset.create({ data: { kind, originalName: file.name, path: relative, sizeBytes: file.size, mimeType: file.type || null, sha256, uploadedBy, }, }); return { id: asset.id, kind: asset.kind as FileKind, sha256: asset.sha256, path: asset.path, sizeBytes: asset.sizeBytes, originalName: asset.originalName, mimeType: asset.mimeType, deduped: false, }; } export async function readFileBytes(relative: string): Promise { const absolute = resolve(uploadRoot(), relative); const root = uploadRoot(); if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) { throw new Error("Invalid file path"); } return readFile(absolute); } export async function deleteFileFromDisk(relative: string): Promise { const absolute = resolve(uploadRoot(), relative); const root = uploadRoot(); if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) { throw new Error("Invalid file path"); } await unlink(absolute).catch(() => undefined); } export function mimeForKind(kind: FileKind, originalMime: string | null): string { if (originalMime) return originalMime; switch (kind) { case "pdf": return "application/pdf"; case "png": return "image/png"; case "jpg": return "image/jpeg"; case "svg": return "image/svg+xml"; case "step": return "application/step"; case "dxf": return "application/dxf"; default: return "application/octet-stream"; } }