Files
mrp-qrcode/lib/files.ts
T
2026-04-21 08:56:51 -05:00

163 lines
4.5 KiB
TypeScript

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<string, FileKind> = {
".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<boolean> {
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<SavedFile> {
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<Buffer> {
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<void> {
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";
}
}