phase 2 and 3
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user