import { promises as fs } from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { encryptBuffer, decryptBuffer } from "@/lib/crypto"; export type KycKind = "ktp" | "selfie"; const KIND_DIRS: Record = { ktp: "ktp", selfie: "selfie", }; /** Bytes. ~5MB matches the form limit; raise here if you change the upload route. */ export const MAX_KYC_FILE_BYTES = 5 * 1024 * 1024; export const ALLOWED_KYC_MIME = new Set([ "image/jpeg", "image/png", "image/webp", ]); const EXT_BY_MIME: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", }; function rootDir(): string { const fromEnv = process.env.KYC_UPLOAD_DIR; if (fromEnv && fromEnv.trim().length > 0) return fromEnv; return path.join(process.cwd(), "uploads", "private"); } function dirFor(kind: KycKind): string { return path.join(rootDir(), KIND_DIRS[kind]); } /** Storage key written into DB: `/.`. The kind segment is enforced to match the route. */ export type StoredFileMeta = { key: string; mime: string; size: number; }; export function isKycKind(value: string): value is KycKind { return value === "ktp" || value === "selfie"; } /** Resolve a storage key (`ktp/abc.jpg`) to an absolute path inside the upload dir. Throws on traversal. */ function resolveKey(kind: KycKind, key: string): string { const expectedPrefix = `${KIND_DIRS[kind]}/`; if (!key.startsWith(expectedPrefix)) { throw new Error("Storage key does not match kind"); } const relative = key.slice(expectedPrefix.length); if (!/^[A-Za-z0-9_-]+\.(jpg|png|webp)$/.test(relative)) { throw new Error("Storage key has invalid characters"); } const abs = path.join(dirFor(kind), relative); const dir = dirFor(kind); if (!abs.startsWith(dir + path.sep) && abs !== dir) { throw new Error("Storage key escapes upload directory"); } return abs; } export async function saveEncrypted( kind: KycKind, data: Buffer, mime: string, ): Promise { if (!ALLOWED_KYC_MIME.has(mime)) throw new Error("Tipe file tidak didukung"); if (data.length === 0) throw new Error("File kosong"); if (data.length > MAX_KYC_FILE_BYTES) throw new Error("File terlalu besar"); const ext = EXT_BY_MIME[mime]; const id = crypto.randomBytes(16).toString("hex"); const key = `${KIND_DIRS[kind]}/${id}.${ext}`; const abs = resolveKey(kind, key); await fs.mkdir(path.dirname(abs), { recursive: true }); const blob = encryptBuffer(data); await fs.writeFile(abs, blob, { mode: 0o600 }); return { key, mime, size: data.length }; } export async function readDecrypted(kind: KycKind, key: string): Promise { const abs = resolveKey(kind, key); const blob = await fs.readFile(abs); return decryptBuffer(blob); } export async function deleteFile(kind: KycKind, key: string): Promise { const abs = resolveKey(kind, key); await fs.rm(abs, { force: true }); } export function mimeFromKey(key: string): string { if (key.endsWith(".jpg")) return "image/jpeg"; if (key.endsWith(".png")) return "image/png"; if (key.endsWith(".webp")) return "image/webp"; return "application/octet-stream"; }