163 lines
4.5 KiB
TypeScript
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";
|
|
}
|
|
}
|