fix upload image trip
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user