149 lines
4.7 KiB
TypeScript
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;
|
|
}
|
|
}
|