import { promises as fs } from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import sharp from "sharp"; /** * Penyimpanan gambar trip — publik, di disk lokal. * * Beda dengan `secure-storage.ts` (KYC): gambar trip TIDAK dienkripsi karena * memang tampil ke semua pengunjung. Tiap gambar dikompres SEKALI saat upload * (resize + WebP), jadi saat render server tinggal serve file statik kecil — * tidak ada fetch URL eksternal, tidak ada masalah DNS. */ /** Batas ukuran file mentah dari user (sebelum kompresi). */ export const MAX_TRIP_IMAGE_UPLOAD_BYTES = 12 * 1024 * 1024; /** MIME yang diterima saat upload. Output selalu dikonversi ke WebP. */ export const ALLOWED_TRIP_IMAGE_MIME = new Set([ "image/jpeg", "image/png", "image/webp", ]); /** Prefix URL publik untuk gambar trip yang dikelola sendiri. */ export const TRIP_IMAGE_URL_PREFIX = "/api/trip-images/"; /** Sisi terpanjang hasil kompresi — cukup tajam untuk hero & cukup ringan. */ const MAX_DIMENSION = 1920; /** Kualitas WebP — 80 = sweet spot kualitas/ukuran. */ const WEBP_QUALITY = 80; /** Nama file valid di disk: `<32-hex>.webp`. Dipakai untuk cegah path traversal. */ const FILE_NAME_RE = /^[a-f0-9]{32}\.webp$/; function rootDir(): string { const fromEnv = process.env.TRIP_UPLOAD_DIR; if (fromEnv && fromEnv.trim().length > 0) return fromEnv; return path.join(process.cwd(), "uploads", "trips"); } export function isValidTripImageName(name: string): boolean { return FILE_NAME_RE.test(name); } /** Resolve nama file ke path absolut di dalam upload dir. Throw kalau mencurigakan. */ function resolveName(name: string): string { if (!isValidTripImageName(name)) { throw new Error("Nama file gambar tidak valid"); } const dir = rootDir(); const abs = path.join(dir, name); if (!abs.startsWith(dir + path.sep)) { throw new Error("Nama file keluar dari direktori upload"); } return abs; } export type StoredTripImage = { /** Nama file di disk, mis. `ab12…ef.webp`. */ name: string; /** URL publik yang disimpan ke `TripImage.url`. */ url: string; /** Ukuran file hasil kompresi (byte). */ size: number; }; /** * Kompres + simpan satu gambar trip. Input mentah (JPG/PNG/WebP) di-resize agar * muat dalam {@link MAX_DIMENSION}², dikonversi ke WebP, dan metadata (EXIF/GPS) * dibuang. Gambar 10MB+ dari kamera HP biasanya menyusut jadi ratusan KB tanpa * kehilangan kualitas yang terlihat. * * `sharp` melempar kalau buffer bukan gambar valid — itu jadi lapis validasi * konten yang lebih kuat dari sekadar percaya `file.type`. */ export async function processAndSaveTripImage( data: Buffer ): Promise { if (data.length === 0) throw new Error("File kosong"); if (data.length > MAX_TRIP_IMAGE_UPLOAD_BYTES) { throw new Error("File terlalu besar"); } let optimized: Buffer; try { optimized = await sharp(data) // `.rotate()` tanpa argumen menerapkan orientasi dari EXIF lalu membuang // metadata — foto HP tidak miring & lokasi GPS user tidak ikut tersimpan. .rotate() .resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, fit: "inside", withoutEnlargement: true, }) .webp({ quality: WEBP_QUALITY }) .toBuffer(); } catch { throw new Error("File bukan gambar yang valid"); } const name = `${crypto.randomBytes(16).toString("hex")}.webp`; const abs = resolveName(name); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, optimized, { mode: 0o644 }); return { name, url: `${TRIP_IMAGE_URL_PREFIX}${name}`, size: optimized.length, }; } export async function readTripImage(name: string): Promise { return fs.readFile(resolveName(name)); } export async function deleteTripImage(name: string): Promise { await fs.rm(resolveName(name), { force: true }); } /** True kalau URL menunjuk gambar trip yang dikelola sendiri (bukan URL eksternal lama). */ export function isManagedTripImageUrl(url: string): boolean { if (!url.startsWith(TRIP_IMAGE_URL_PREFIX)) return false; return isValidTripImageName(url.slice(TRIP_IMAGE_URL_PREFIX.length)); } /** List semua nama file gambar yang ada di disk (untuk cron cleanup). */ export async function listTripImageNames(): Promise { try { const entries = await fs.readdir(rootDir()); return entries.filter(isValidTripImageName); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return []; throw err; } } /** mtime file. Null kalau file tidak ada. */ export async function tripImageMtime(name: string): Promise { try { const st = await fs.stat(resolveName(name)); return st.mtime; } catch { return null; } }