Files
setrip/lib/trip-image-storage.ts
T
2026-05-22 14:52:22 +07:00

149 lines
4.7 KiB
TypeScript

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<StoredTripImage> {
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<Buffer> {
return fs.readFile(resolveName(name));
}
export async function deleteTripImage(name: string): Promise<void> {
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<string[]> {
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<Date | null> {
try {
const st = await fs.stat(resolveName(name));
return st.mtime;
} catch {
return null;
}
}