diff --git a/.env.example b/.env.example index f16fa27..f396ade 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ KYC_ENCRYPTION_KEY= KYC_NIK_PEPPER= # Absolute path for private KYC uploads (default: /uploads/private) KYC_UPLOAD_DIR= +# Absolute path for public trip image uploads (default: /uploads/trips) +# Pakai volume persisten — file di sini harus selamat saat redeploy/restart. +TRIP_UPLOAD_DIR= GOOGLE_CLIENT_ID="xxxxxxxx" GOOGLE_CLIENT_SECRET="xxxxxxxx" diff --git a/.gitignore b/.gitignore index 01328fe..523d756 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,8 @@ yarn-error.log* .env.development .env.local -# private uploads (KYC: KTP / liveness). Never serve directly. +# runtime uploads — KYC (encrypted, private) & trip images (public, served via +# /api/trip-images). User data, not source: keep out of git, back up separately. /uploads/ # vercel diff --git a/app/(public)/trips/[id]/opengraph-image.tsx b/app/(public)/trips/[id]/opengraph-image.tsx index 4edd1f6..f007b2c 100644 --- a/app/(public)/trips/[id]/opengraph-image.tsx +++ b/app/(public)/trips/[id]/opengraph-image.tsx @@ -2,7 +2,7 @@ import { ImageResponse } from "next/og"; import { tripService } from "@/server/services/trip.service"; import { formatRupiah } from "@/lib/utils"; import { formatTripCalendarDateRangeLong } from "@/lib/trip-dates"; -import { siteConfig } from "@/lib/site"; +import { siteConfig, siteUrl } from "@/lib/site"; export const alt = `${siteConfig.name} — Open Trip & Aktivitas Bareng`; export const size = { width: 1200, height: 630 }; @@ -43,7 +43,15 @@ export default async function TripOgImage({ ); } - const cover = trip.images[0]?.url; + // Satori (ImageResponse) mem-fetch gambar server-side dan butuh URL absolut. + // Foto trip baru disimpan sebagai path relatif `/api/trip-images/...` — + // prefix dengan origin. Foto lama (URL eksternal absolut) dipakai apa adanya. + const coverRaw = trip.images[0]?.url; + const cover = coverRaw + ? coverRaw.startsWith("http") + ? coverRaw + : `${siteUrl}${coverRaw}` + : undefined; const dateLabel = formatTripCalendarDateRangeLong(trip.date, trip.endDate); const price = formatRupiah(trip.price); diff --git a/app/(public)/trips/page.tsx b/app/(public)/trips/page.tsx index aa45cc5..1d7e9e1 100644 --- a/app/(public)/trips/page.tsx +++ b/app/(public)/trips/page.tsx @@ -153,7 +153,7 @@ export default async function TripsPage({ searchParams }: TripsPageProps) { ) : (
- {trips.map((trip) => ( + {trips.map((trip, index) => ( { } // Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler. -const TRACKED_JOBS = ["auto-complete-trips", "process-email-jobs"] as const; +const TRACKED_JOBS = [ + "auto-complete-trips", + "process-email-jobs", + "cleanup-trip-images", +] as const; function healthOf(summary: JobSummary): "ok" | "stale" | "failed" { if (summary.lastRun?.status === "FAILED") return "failed"; diff --git a/app/api/cron/cleanup-trip-images/route.ts b/app/api/cron/cleanup-trip-images/route.ts new file mode 100644 index 0000000..59f2867 --- /dev/null +++ b/app/api/cron/cleanup-trip-images/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { runCron } from "@/lib/cron-runner"; +import { prisma } from "@/lib/prisma"; +import { + deleteTripImage, + listTripImageNames, + tripImageMtime, + TRIP_IMAGE_URL_PREFIX, +} from "@/lib/trip-image-storage"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** File yang lebih tua dari ini & tak direferensikan DB dianggap yatim. */ +const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * Cron — hapus file gambar trip yatim. + * + * Form create-trip multi-step mengunggah foto SEBELUM trip tersimpan; kalau + * user menutup form di tengah jalan, file menggantung di disk tanpa pernah + * jadi `TripImage`. Sweep ini menghapus file >24 jam yang tidak direferensikan + * `TripImage` mana pun. Idempotent — aman dijalankan berulang. + * + * Trigger: lihat docs/CRON_SETUP.md. Header wajib `Authorization: Bearer ${CRON_SECRET}`. + */ +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret) { + console.error("[cron/cleanup-trip-images] CRON_SECRET tidak di-set"); + return NextResponse.json( + { error: "Server misconfigured" }, + { status: 500 } + ); + } + if (req.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const outcome = await runCron("cleanup-trip-images", async () => { + const names = await listTripImageNames(); + if (names.length === 0) return { scanned: 0, deleted: 0 }; + + const referenced = await prisma.tripImage.findMany({ + where: { url: { startsWith: TRIP_IMAGE_URL_PREFIX } }, + select: { url: true }, + }); + const referencedNames = new Set( + referenced.map((r) => r.url.slice(TRIP_IMAGE_URL_PREFIX.length)) + ); + + const now = Date.now(); + let deleted = 0; + for (const name of names) { + if (referencedNames.has(name)) continue; + const mtime = await tripImageMtime(name); + // File baru di-upload tapi trip belum tersimpan → beri tenggang 24 jam. + if (!mtime || now - mtime.getTime() < ORPHAN_AGE_MS) continue; + await deleteTripImage(name); + deleted++; + } + return { scanned: names.length, deleted }; + }); + + if (!outcome.ok) { + console.error("[cron/cleanup-trip-images] gagal", outcome.error); + return NextResponse.json( + { error: "Gagal menjalankan cleanup" }, + { status: 500 } + ); + } + console.log("[cron/cleanup-trip-images] selesai", outcome.payload); + return NextResponse.json({ ok: true, ...outcome.payload }); +} diff --git a/app/api/trip-images/[name]/route.ts b/app/api/trip-images/[name]/route.ts new file mode 100644 index 0000000..851baa1 --- /dev/null +++ b/app/api/trip-images/[name]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isValidTripImageName, readTripImage } from "@/lib/trip-image-storage"; + +export const runtime = "nodejs"; + +interface RouteCtx { + params: Promise<{ name: string }>; +} + +/** + * Sajikan gambar trip dari disk lokal. Publik — gambar trip memang tampil ke + * semua pengunjung. Di-cache `immutable` selama setahun: nama file + * content-addressed (hex acak), jadi konten untuk satu nama tidak pernah + * berubah. Beban render = baca file kecil dari disk, tanpa fetch eksternal. + */ +export async function GET(_req: NextRequest, ctx: RouteCtx) { + const { name } = await ctx.params; + if (!isValidTripImageName(name)) { + return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 }); + } + + let data: Buffer; + try { + data = await readTripImage(name); + } catch { + return NextResponse.json({ error: "Tidak ditemukan" }, { status: 404 }); + } + + return new NextResponse(new Uint8Array(data), { + status: 200, + headers: { + "Content-Type": "image/webp", + "Content-Length": String(data.length), + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/upload/trip-image/route.ts b/app/api/upload/trip-image/route.ts new file mode 100644 index 0000000..a5924d0 --- /dev/null +++ b/app/api/upload/trip-image/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { requireActiveUser } from "@/lib/auth-guards"; +import { + ALLOWED_TRIP_IMAGE_MIME, + MAX_TRIP_IMAGE_UPLOAD_BYTES, + processAndSaveTripImage, +} from "@/lib/trip-image-storage"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Upload satu foto trip. Dipanggil dari form create-trip saat user memilih + * file — gambar langsung dikompres & disimpan, route mengembalikan URL publik + * yang nanti ikut disubmit bersama data trip. + * + * File yatim (di-upload tapi trip batal dibuat) dibersihkan cron + * `/api/cron/cleanup-trip-images`. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + await requireActiveUser(session.user.id); + } catch (err) { + return NextResponse.json( + { error: (err as Error).message }, + { status: 403 } + ); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return NextResponse.json( + { error: "Body bukan multipart/form-data" }, + { status: 400 } + ); + } + + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "File wajib diisi" }, { status: 400 }); + } + if (!ALLOWED_TRIP_IMAGE_MIME.has(file.type)) { + return NextResponse.json( + { error: "Hanya menerima JPG, PNG, atau WebP" }, + { status: 415 } + ); + } + if (file.size > MAX_TRIP_IMAGE_UPLOAD_BYTES) { + return NextResponse.json( + { error: "Ukuran file maksimal 12MB" }, + { status: 413 } + ); + } + + try { + const buf = Buffer.from(await file.arrayBuffer()); + const saved = await processAndSaveTripImage(buf); + return NextResponse.json({ url: saved.url, size: saved.size }); + } catch (err) { + return NextResponse.json( + { error: (err as Error).message || "Gagal memproses gambar" }, + { status: 400 } + ); + } +} diff --git a/docs/CRON_SETUP.md b/docs/CRON_SETUP.md index bff8a47..a837128 100644 --- a/docs/CRON_SETUP.md +++ b/docs/CRON_SETUP.md @@ -12,6 +12,7 @@ Aplikasi punya beberapa endpoint cron yang harus di-trigger periodik dari luar. |---|---|---|---|---| | 1 | `GET /api/cron/auto-complete-trips` | `0 18 * * *` | Daily 01:00 WIB (18:00 UTC) | Flip trip yang sudah lewat tanggal selesai dari `OPEN`/`FULL` ke `COMPLETED`. Setelah itu, release payout HELD yang sudah lewat `heldUntil`. | | 2 | `GET /api/cron/process-email-jobs` | `*/5 * * * *` | Setiap 5 menit | Drain retry queue email — pick `EmailJob` status `PENDING`/`FAILED` (attempts<5), retry via Resend dengan exponential backoff. | +| 3 | `GET /api/cron/cleanup-trip-images` | `30 18 * * *` | Daily 01:30 WIB (18:30 UTC) | Hapus file gambar trip yatim — foto yang di-upload di form create-trip tapi trip-nya batal dibuat. Hanya file >24 jam yang tak direferensikan `TripImage`. | Semua cron pakai pola yang sama: header `Authorization: Bearer ${CRON_SECRET}`, idempotent, auto-log ke `CronRun`. Tambah cron baru = tambah baris di tabel ini + tabel `TRACKED_JOBS` di [app/admin/system/page.tsx](../app/admin/system/page.tsx). @@ -67,6 +68,9 @@ Tambah baris berikut (ganti `https://your-domain.com` dan `` sesuai # 2. Drain email retry queue (setiap 5 menit) */5 * * * * curl -fsS -H "Authorization: Bearer " https://your-domain.com/api/cron/process-email-jobs >> /var/log/setrip-cron.log 2>&1 + +# 3. Bersihkan gambar trip yatim (daily 01:30 WIB) +30 18 * * * curl -fsS -H "Authorization: Bearer " https://your-domain.com/api/cron/cleanup-trip-images >> /var/log/setrip-cron.log 2>&1 ``` Verifikasi crontab tersimpan: @@ -120,6 +124,7 @@ curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cr |---|---|---| | `auto-complete-trips` | `{"ok":true,"completed":0,"ids":[],"payoutsReleased":[]}` | `{"ok":true,"completed":2,"ids":["clx...","cly..."],"payoutsReleased":["..."]}` | | `process-email-jobs` | `{"ok":true,"picked":0,"succeeded":0,"failed":0}` | `{"ok":true,"picked":5,"succeeded":5,"failed":0}` | +| `cleanup-trip-images` | `{"ok":true,"scanned":0,"deleted":0}` | `{"ok":true,"scanned":12,"deleted":3}` | **Error response:** - **401** — `CRON_SECRET` di env tidak match dengan header. Cek `pm2 env `. diff --git a/features/trip/components/create-trip-form.tsx b/features/trip/components/create-trip-form.tsx index 4e16b8b..3594f0e 100644 --- a/features/trip/components/create-trip-form.tsx +++ b/features/trip/components/create-trip-form.tsx @@ -12,7 +12,7 @@ import { } from "lucide-react"; import { DateRangeField, TimeField } from "@/components/shared/date-picker"; import { createTripAction } from "@/features/trip/actions"; -import { ImageUrlInput } from "@/features/trip/components/image-url-input"; +import { TripImageUpload } from "@/features/trip/components/trip-image-upload"; import { formatLocalCalendarYmd } from "@/lib/trip-dates"; import { ACTIVITY_CATEGORIES, categoryMeta } from "@/lib/activity-category"; import { VIBES, vibeMeta } from "@/lib/vibe"; @@ -61,7 +61,7 @@ const INITIAL_STATE: FormState = { itineraryDays: [], whatsIncluded: "", whatsExcluded: "", - imageUrls: [""], + imageUrls: [], maxParticipants: "", priceDisplay: "", }; @@ -152,18 +152,11 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) { return null; } if (target === 3) { - const hasInvalidUrl = state.imageUrls - .map((u) => u.trim()) - .filter(Boolean) - .some((u) => { - try { - const parsed = new URL(u); - return parsed.protocol !== "http:" && parsed.protocol !== "https:"; - } catch { - return true; - } - }); - if (hasInvalidUrl) return "Ada URL foto yang tidak valid (harus http/https)"; + // Foto divalidasi saat upload (route + komponen). Di sini cukup cek + // batas jumlah supaya tidak melampaui kapasitas. + if (state.imageUrls.length > LIMITS.MAX_IMAGE_URLS) { + return `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`; + } for (let d = 0; d < state.itineraryDays.length; d++) { const dayItems = state.itineraryDays[d]; @@ -338,6 +331,7 @@ export function CreateTripForm({ isVerifiedOrganizer }: CreateTripFormProps) { whatsExcluded={state.whatsExcluded} imageUrls={state.imageUrls} onChange={update} + onError={setStepError} /> )} @@ -698,6 +692,7 @@ function StepDetail({ whatsExcluded, imageUrls, onChange, + onError, }: { meetingPoint: string; itineraryDays: ItineraryDays; @@ -705,6 +700,7 @@ function StepDetail({ whatsExcluded: string; imageUrls: string[]; onChange: (key: K, value: FormState[K]) => void; + onError: (msg: string) => void; }) { return (
@@ -777,9 +773,10 @@ function StepDetail({
- onChange("imageUrls", urls)} + onError={onError} /> ); diff --git a/features/trip/components/image-gallery.tsx b/features/trip/components/image-gallery.tsx index 6674a10..b6c7833 100644 --- a/features/trip/components/image-gallery.tsx +++ b/features/trip/components/image-gallery.tsx @@ -1,8 +1,8 @@ "use client"; import Image from "next/image"; -import { useState } from "react"; -import { Mountain } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Mountain, Maximize2, X, ChevronLeft, ChevronRight } from "lucide-react"; interface TripImage { id: string; @@ -12,6 +12,38 @@ interface TripImage { export function ImageGallery({ images }: { images: TripImage[] }) { const [activeIndex, setActiveIndex] = useState(0); + const [lightboxOpen, setLightboxOpen] = useState(false); + const hasMultiple = images.length > 1; + + function showPrev() { + setActiveIndex((i) => (i - 1 + images.length) % images.length); + } + function showNext() { + setActiveIndex((i) => (i + 1) % images.length); + } + + // Saat lightbox terbuka: kunci scroll body + dukung keyboard (Esc tutup, + // panah kiri/kanan untuk ganti foto). + useEffect(() => { + if (!lightboxOpen) return; + function onKey(e: KeyboardEvent) { + // Logika prev/next di-inline (bukan panggil showPrev/showNext) supaya + // effect tidak bergantung pada fungsi yang dibuat ulang tiap render. + if (e.key === "Escape") setLightboxOpen(false); + else if (e.key === "ArrowLeft") { + setActiveIndex((i) => (i - 1 + images.length) % images.length); + } else if (e.key === "ArrowRight") { + setActiveIndex((i) => (i + 1) % images.length); + } + } + document.addEventListener("keydown", onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + }; + }, [lightboxOpen, images.length]); if (images.length === 0) { return ( @@ -30,18 +62,25 @@ export function ImageGallery({ images }: { images: TripImage[] }) { return (
- {/* Main Image */} -
+ {/* Main Image — klik untuk lihat ukuran penuh */} + {/* Thumbnails */} - {images.length > 1 && ( + {hasMultiple && (
{images.map((img, i) => (
)} + + {/* Lightbox — penampil foto ukuran penuh */} + {lightboxOpen && ( +
+ {/* Top bar */} +
+ + {activeIndex + 1} / {images.length} + + +
+ + {/* Area gambar — klik latar untuk menutup */} +
setLightboxOpen(false)} + > +
e.stopPropagation()} + > + {activeImage.caption +
+ + {hasMultiple && ( + <> + + + + )} +
+ + {activeImage.caption && ( +

+ {activeImage.caption} +

+ )} + + {/* Thumbnail strip di dalam lightbox */} + {hasMultiple && ( +
+ {images.map((img, i) => ( + + ))} +
+ )} +
+ )}
); } diff --git a/features/trip/components/image-url-input.tsx b/features/trip/components/image-url-input.tsx deleted file mode 100644 index 16e6d15..0000000 --- a/features/trip/components/image-url-input.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { Plus, X } from "lucide-react"; -import { LIMITS } from "@/lib/limits"; - -interface ImageUrlInputProps { - value: string[]; - onChange: (urls: string[]) => void; -} - -export function ImageUrlInput({ value, onChange }: ImageUrlInputProps) { - const urls = value.length > 0 ? value : [""]; - const max = LIMITS.MAX_IMAGE_URLS; - - function addField() { - if (urls.length < max) { - onChange([...urls, ""]); - } - } - - function removeField(index: number) { - const next = urls.filter((_, i) => i !== index); - onChange(next.length > 0 ? next : [""]); - } - - function updateField(index: number, next: string) { - const updated = [...urls]; - updated[index] = next; - onChange(updated); - } - - return ( -
- -
- {urls.map((url, i) => ( -
- updateField(i, e.target.value)} - className="flex-1 rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white" - placeholder={ - i === 0 - ? "URL foto utama (cover)" - : `URL foto ${i + 1} (opsional)` - } - /> - {urls.length > 1 && ( - - )} -
- ))} -
- {urls.length < max && ( - - )} -

- Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya di sini. -

-
- ); -} diff --git a/features/trip/components/trip-image-upload.tsx b/features/trip/components/trip-image-upload.tsx new file mode 100644 index 0000000..1c7707d --- /dev/null +++ b/features/trip/components/trip-image-upload.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useRef, useState } from "react"; +import Image from "next/image"; +import { ImagePlus, X, Loader2 } from "lucide-react"; +import { LIMITS } from "@/lib/limits"; + +interface TripImageUploadProps { + /** URL gambar yang sudah terunggah (path `/api/trip-images/...`). */ + value: string[]; + onChange: (urls: string[]) => void; + /** Lapor error ke form (mis. file terlalu besar / gagal upload). */ + onError?: (msg: string) => void; +} + +const ACCEPT_MIME = "image/jpeg,image/png,image/webp"; +/** Sinkron dengan MAX_TRIP_IMAGE_UPLOAD_BYTES di lib/trip-image-storage.ts. */ +const MAX_BYTES = 12 * 1024 * 1024; + +/** + * Pengganti input URL foto: user memilih file dari perangkatnya, tiap file + * langsung di-upload & dikompres server-side. Form hanya menyimpan URL hasil. + */ +export function TripImageUpload({ + value, + onChange, + onError, +}: TripImageUploadProps) { + const inputRef = useRef(null); + const [uploadingCount, setUploadingCount] = useState(0); + const max = LIMITS.MAX_IMAGE_URLS; + const usedSlots = value.length + uploadingCount; + const remaining = max - usedSlots; + + async function uploadOne(file: File): Promise { + if (!ACCEPT_MIME.split(",").includes(file.type)) { + onError?.(`"${file.name}" harus JPG, PNG, atau WebP`); + return null; + } + if (file.size > MAX_BYTES) { + onError?.(`"${file.name}" melebihi 12MB`); + return null; + } + const fd = new FormData(); + fd.set("file", file); + try { + const res = await fetch("/api/upload/trip-image", { + method: "POST", + body: fd, + }); + const json = await res.json(); + if (!res.ok) { + onError?.(json.error ?? `Gagal mengunggah "${file.name}"`); + return null; + } + return json.url as string; + } catch { + onError?.(`Gagal mengunggah "${file.name}"`); + return null; + } + } + + async function handlePick(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + e.target.value = ""; + if (files.length === 0) return; + + if (remaining <= 0) { + onError?.(`Maksimal ${max} foto`); + return; + } + const picked = files.slice(0, remaining); + if (files.length > remaining) { + onError?.(`Hanya ${remaining} foto pertama diunggah (maks ${max})`); + } + + setUploadingCount((c) => c + picked.length); + // `value` di-snapshot saat handler dibuat; upload sekuensial supaya urutan + // foto stabil, lalu hasil yang berhasil ditambahkan sekali di akhir. + const uploaded: string[] = []; + for (const file of picked) { + const url = await uploadOne(file); + setUploadingCount((c) => c - 1); + if (url) uploaded.push(url); + } + if (uploaded.length > 0) onChange([...value, ...uploaded]); + } + + function removeAt(index: number) { + onChange(value.filter((_, i) => i !== index)); + } + + return ( +
+ + +
+ {value.map((url, i) => ( +
+ {i + {i === 0 && ( + + Cover + + )} + +
+ ))} + + {Array.from({ length: uploadingCount }).map((_, i) => ( +
+ +
+ ))} + + {remaining > 0 && ( + + )} +
+ + + +

+ Unggah langsung dari galeri/kamera — JPG, PNG, atau WebP, maks 12MB per + foto. Foto pertama jadi cover. Gambar besar otomatis dikompres tanpa + mengorbankan kualitas. +

+
+ ); +} diff --git a/features/trip/schemas.ts b/features/trip/schemas.ts index aafc8a9..b2286d0 100644 --- a/features/trip/schemas.ts +++ b/features/trip/schemas.ts @@ -9,13 +9,21 @@ import { } from "@/lib/trip-dates"; import { isValidTimeFormat, timeToMinutes } from "@/lib/itinerary"; +/** + * Foto trip sekarang adalah file yang diunggah ke server sendiri, bukan URL + * eksternal. Nilai yang valid hanya path terkelola `/api/trip-images/.webp` + * yang dihasilkan route upload — regex ini sengaja ketat supaya URL arbitrary + * (yang dulu sering tidak reachable dari server) tidak bisa lolos lagi. + */ export const tripImageUrlsSchema = z .array( z .string() .trim() - .max(LIMITS.MAX_URL_LENGTH, "URL terlalu panjang") - .url("Setiap URL gambar harus valid (http/https)") + .regex( + /^\/api\/trip-images\/[a-f0-9]{32}\.webp$/, + "Foto trip tidak valid — silakan unggah ulang fotonya" + ) ) .max(LIMITS.MAX_IMAGE_URLS, `Maksimal ${LIMITS.MAX_IMAGE_URLS} foto`); diff --git a/lib/trip-image-storage.ts b/lib/trip-image-storage.ts new file mode 100644 index 0000000..3f1d695 --- /dev/null +++ b/lib/trip-image-storage.ts @@ -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 { + 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; + } +} diff --git a/next.config.ts b/next.config.ts index 1b2b1b8..1c36ef4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,8 @@ const nextConfig: NextConfig = { dangerouslyAllowSVG: true, // AVIF didahulukan — ~30% lebih kecil dari WebP, didukung browser modern. formats: ["image/avif", "image/webp"], + // 75 = default (kartu/thumbnail); 90 = lightbox foto trip ukuran penuh. + qualities: [75, 90], // Cache hasil optimasi minimal 1 hari supaya tidak re-optimize tiap request. minimumCacheTTL: 86400, remotePatterns: [ diff --git a/package-lock.json b/package-lock.json index 6d86101..7f5aee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-datepicker": "^9.1.0", "react-dom": "19.2.4", "resend": "^6.12.3", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "devDependencies": { @@ -1070,7 +1071,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -4051,7 +4051,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7790,7 +7789,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -7834,7 +7832,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index ae7b156..de2d1a7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-datepicker": "^9.1.0", "react-dom": "19.2.4", "resend": "^6.12.3", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "devDependencies": {