103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
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" | "liveness";
|
|
|
|
const KIND_DIRS: Record<KycKind, string> = {
|
|
ktp: "ktp",
|
|
liveness: "liveness",
|
|
};
|
|
|
|
/** 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<string, string> = {
|
|
"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: `<kind>/<id>.<ext>`. 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 === "liveness";
|
|
}
|
|
|
|
/** 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<StoredFileMeta> {
|
|
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<Buffer> {
|
|
const abs = resolveKey(kind, key);
|
|
const blob = await fs.readFile(abs);
|
|
return decryptBuffer(blob);
|
|
}
|
|
|
|
export async function deleteFile(kind: KycKind, key: string): Promise<void> {
|
|
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";
|
|
}
|