import type * as __ManduFsTypes0 from "fs"; /** * Mandu Image Handler * /_mandu/image?url=...&w=...&q=... 엔드포인트 * 온디맨드 리사이즈 + WebP/AVIF 포맷 협상 + 캐시 */ import path from "path"; // ========== Types ========== interface ImageOptions { width: number; quality: number; format: "webp" | "jpeg" | "png" | "avif"; sourceContentType?: string; } interface ProcessedImageResult { data: Uint8Array; contentType: string; } // ========== Cache ========== const imageCache = new Map(); const MAX_IMAGE_CACHE = 500; // ========== Handler ========== /** * 이미지 최적화 요청 처리 * /_mandu/image?url=/photos/hero.jpg&w=800&q=80 */ export async function handleImageRequest( request: Request, rootDir: string, publicDir: string = "public" ): Promise { const url = new URL(request.url); if (url.pathname !== "/_mandu/image") return null; const src = url.searchParams.get("url"); const width = Number(url.searchParams.get("w") ?? 800); const quality = Number(url.searchParams.get("q") ?? 80); if (!src || width < 1 || width > 4096 || quality < 1 || quality > 100) { return new Response("Invalid image parameters", { status: 400 }); } // 보안: src가 /로 시작하고 traversal/null byte 없는지 확인 if (!src.startsWith("/") || src.includes("..") || src.includes("\0")) { return new Response("Invalid image path", { status: 400 }); } // 포맷 협상 (Accept 헤더 기반) const format = negotiateFormat(request); const cacheKey = `${src}:${width}:${quality}:${format}`; // 캐시 확인 const cached = imageCache.get(cacheKey); if (cached) { return new Response(cached.data as unknown as BodyInit, { headers: { "Content-Type": cached.contentType, "Cache-Control": "public, max-age=31536000, immutable", "Vary": "Accept", "X-Mandu-Image-Cache": "HIT", }, }); } // 원본 파일 경로 해석 + symlink traversal 방지 const allowedBaseDir = path.resolve(rootDir, publicDir); const filePath = path.join(allowedBaseDir, src.slice(1)); // realpath로 symlink를 해석한 후 allowedBaseDir 내부인지 검증 let resolvedPath: string; try { const realFs = require("fs") as typeof __ManduFsTypes0; resolvedPath = realFs.realpathSync(filePath); const resolvedBase = realFs.realpathSync(allowedBaseDir); if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { return new Response("Forbidden", { status: 403 }); } } catch { // realpath 실패 (broken symlink, 파일 없음) → 안전하게 404 반환 return new Response("Image not found", { status: 404 }); } const file = Bun.file(resolvedPath); if (!await file.exists()) { return new Response("Image not found", { status: 404 }); } try { const original = await file.arrayBuffer(); const processed = await processImage(new Uint8Array(original), { width, quality, format, sourceContentType: getMimeForExtension(src), }); // 캐시 저장 (LRU) if (imageCache.size >= MAX_IMAGE_CACHE) { const oldest = imageCache.keys().next().value; if (oldest !== undefined) imageCache.delete(oldest); } imageCache.set(cacheKey, { data: processed.data, contentType: processed.contentType, }); return new Response(processed.data as unknown as BodyInit, { headers: { "Content-Type": processed.contentType, "Cache-Control": "public, max-age=31536000, immutable", "Vary": "Accept", }, }); } catch (error) { console.error(`[Mandu Image] Processing failed for ${src}:`, error); // 원본 파일 그대로 반환 (fallback) return new Response(file, { headers: { "Content-Type": getMimeForExtension(src), "Cache-Control": "public, max-age=86400", }, }); } } // ========== Format Negotiation ========== function negotiateFormat(request: Request): "webp" | "jpeg" | "png" | "avif" { const accept = request.headers.get("Accept") ?? ""; if (accept.includes("image/avif")) return "avif"; if (accept.includes("image/webp")) return "webp"; return "jpeg"; } function getMimeForExtension(src: string): string { const ext = path.extname(src).toLowerCase(); const map: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", }; return map[ext] ?? "application/octet-stream"; } // ========== Image Processing ========== /** * 이미지 리사이즈 + 포맷 변환 * Bun의 내장 sharp 미지원 시 원본 반환 (graceful degradation) */ async function processImage( data: Uint8Array, options: ImageOptions ): Promise { // sharp 사용 시도 (선택적 의존성) try { // sharp is a heavyweight optional native dep. Type it minimally to the // tiny surface we actually call — `resize/webp/avif/jpeg/png/toBuffer`. interface SharpPipeline { resize(width: number): SharpPipeline; webp(opts: { quality: number }): SharpPipeline; avif(opts: { quality: number }): SharpPipeline; jpeg(opts: { quality: number }): SharpPipeline; png(opts: { quality: number }): SharpPipeline; toBuffer(): Promise; } type SharpFn = (input: Buffer) => SharpPipeline; const sharp = require("sharp") as SharpFn; let pipeline = sharp(Buffer.from(data)).resize(options.width); switch (options.format) { case "webp": pipeline = pipeline.webp({ quality: options.quality }); break; case "avif": pipeline = pipeline.avif({ quality: options.quality }); break; case "jpeg": pipeline = pipeline.jpeg({ quality: options.quality }); break; case "png": pipeline = pipeline.png({ quality: options.quality }); break; } const result = await pipeline.toBuffer(); return { data: new Uint8Array(result), contentType: `image/${options.format}`, }; } catch { // sharp 미설치 시 원본 반환 return { data, contentType: options.sourceContentType ?? "application/octet-stream", }; } }