From e24d78cd1577ef07da648ad26c630963471ea192 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Fri, 29 May 2026 15:43:04 +0000 Subject: [PATCH] images: unified server-side optimization pipeline + /api/upload/image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single shared sharp-based pipeline (src/lib/images/optimize.ts) used by every upload surface going forward — PR submissions, contributor articles, featured-story admin, forum composer attachments, avatars. Resizes to 4096px longest side max, emits AVIF + WebP + format-preserving fallback (JPEG/PNG) + 320px thumbnail, all at perceptual-lossless q=82. POST /api/upload/image is the universal endpoint: auth-gated (signed-in Supabase user required), 20MB input cap, MIME-validated, processes the buffer, uploads every derivative to the bb-media bucket under ///.{webp,avif,jpg,png} with year-long immutable caching. Returns dimensions + all derivative URLs + a primaryUrl convenience field for simple embedding. Bucket reconfigured: file_size_limit bumped 10MB → 25MB and image/avif added to allowed_mime_types so AVIF derivatives accept. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/upload/image/route.ts | 119 +++++++++++++++++++++++++ src/lib/images/optimize.ts | 139 ++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/app/api/upload/image/route.ts create mode 100644 src/lib/images/optimize.ts diff --git a/src/app/api/upload/image/route.ts b/src/app/api/upload/image/route.ts new file mode 100644 index 0000000..cfd05bb --- /dev/null +++ b/src/app/api/upload/image/route.ts @@ -0,0 +1,119 @@ +/** + * POST /api/upload/image + * + * Universal image upload endpoint. Every surface (forum composer, PR + * submission, contributor article, featured-story admin, avatar update, + * banner upload, anything else) POSTs here. + * + * Auth: requires a logged-in Supabase user. Anonymous uploads are + * rejected to keep the bucket from being abused as a public file dump. + * + * Pipeline (see lib/images/optimize.ts): + * - sharp resize to ≤4096px longest side + * - AVIF + WebP + format-preserving fallback (JPEG/PNG) at q=82 + * - 320px thumbnail + * + * Storage: writes to the `bb-media` Supabase bucket under + * ////.{webp,avif,jpg,png} + * and returns the public URLs + dimensions so the client can either + * embed an or paste a markdown ![alt](url) into their post. + */ +import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; +import { createClient as createServer } from "@/lib/supabase/server"; +import { createClient as createService } from "@supabase/supabase-js"; +import { optimizeImage } from "@/lib/images/optimize"; + +const BUCKET = "bb-media"; +const MAX_INPUT_BYTES = 20 * 1024 * 1024; // 20 MB hard cap on input + +export async function POST(req: NextRequest) { + // ── 1. Auth ────────────────────────────────────────────────────────────── + const sb = await createServer(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Not signed in" }, { status: 401 }); + } + + // ── 2. Read upload ─────────────────────────────────────────────────────── + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 }); + } + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "Missing file" }, { status: 400 }); + } + if (file.size > MAX_INPUT_BYTES) { + return NextResponse.json( + { error: `Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 20 MB.` }, + { status: 413 }, + ); + } + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "Not an image" }, { status: 415 }); + } + + const input = Buffer.from(await file.arrayBuffer()); + + // ── 3. Optimize ────────────────────────────────────────────────────────── + let opt; + try { + opt = await optimizeImage(input); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "image decode failed"; + return NextResponse.json({ error: `Could not process image: ${msg}` }, { status: 422 }); + } + + // ── 4. Upload derivatives ──────────────────────────────────────────────── + const url = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; + if (!url || !key) { + return NextResponse.json({ error: "Storage not configured" }, { status: 500 }); + } + const svc = createService(url, key, { auth: { persistSession: false } }); + + const now = new Date(); + const dir = `${user.id}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`; + const stub = randomBytes(8).toString("hex"); + + async function upload(suffix: string, body: Buffer, contentType: string): Promise { + const path = `${dir}/${stub}${suffix}`; + const { error } = await svc.storage.from(BUCKET).upload(path, body, { + contentType, upsert: false, cacheControl: "31536000, immutable", + }); + if (error) { + console.error("storage upload failed", path, error); + return null; + } + return svc.storage.from(BUCKET).getPublicUrl(path).data.publicUrl; + } + + const urls: Record = {}; + if (opt.avif) urls.avif = await upload(".avif", opt.avif, "image/avif"); + /* webp is mandatory */ urls.webp = await upload(".webp", opt.webp, "image/webp"); + if (opt.jpeg) urls.jpeg = await upload(".jpg", opt.jpeg, "image/jpeg"); + if (opt.png) urls.png = await upload(".png", opt.png, "image/png"); + if (opt.thumbnail) urls.thumbnail = await upload("_thumb.jpg", opt.thumbnail, "image/jpeg"); + + if (!urls.webp) { + return NextResponse.json({ error: "Storage upload failed" }, { status: 500 }); + } + + // ── 5. Return URLs + metadata ─────────────────────────────────────────── + return NextResponse.json({ + ok: true, + width: opt.width, + height: opt.height, + format: opt.format, + hasAlpha: opt.hasAlpha, + urls, + bytes: opt.bytes, + // Convenience: the most-compatible URL the caller should embed by default. + // Browsers that support AVIF will pick it via ; this URL is the + // safe-default . + primaryUrl: urls.webp, + }); +} diff --git a/src/lib/images/optimize.ts b/src/lib/images/optimize.ts new file mode 100644 index 0000000..7c8cf75 --- /dev/null +++ b/src/lib/images/optimize.ts @@ -0,0 +1,139 @@ +/** + * Unified server-side image optimization pipeline. + * + * Every image upload across the site flows through this module: PR firm + * featured images, contributor uploads, forum post attachments, author + * avatars, banner creatives, anything else. Single source of truth so + * every surface produces the same multi-format ladder and stays under + * sane size caps. + * + * Output per input: + * - AVIF (best compression, all current browsers) + * - WebP (universal fallback) + * - JPEG (final fallback for very old clients, preserved if input was JPEG) + * - thumbnail (320px, JPEG) for in-feed cards + * + * Quality: q=82 visually lossless for photos. Compression by default keeps + * transparency on PNGs. + * + * Resize policy: longest-side cap of 4096px (prevents 12MP DSLR exports from + * sitting un-resized on our CDN). Caller can override `maxSide`. + * + * Returns Buffers for the caller to upload to Supabase Storage. + */ +import sharp from "sharp"; + +export interface OptimizeOptions { + /** Longest side in pixels — default 4096. */ + maxSide?: number; + /** Compression quality 1-100 — default 82 (perceptually lossless for photos). */ + quality?: number; + /** Skip thumbnail generation when caller doesn't need it. */ + thumbnail?: boolean; + /** Skip AVIF (slow encode) when we only need WebP. */ + avif?: boolean; +} + +export interface OptimizedImage { + width: number; + height: number; + format: string; // original input format + hasAlpha: boolean; + avif?: Buffer; + webp: Buffer; + jpeg?: Buffer; + png?: Buffer; + thumbnail?: Buffer; // always JPEG, 320px wide + bytes: { + original: number; + avif?: number; + webp: number; + jpeg?: number; + png?: number; + thumbnail?: number; + }; +} + +export async function optimizeImage( + input: Buffer, + opts: OptimizeOptions = {}, +): Promise { + const maxSide = opts.maxSide ?? 4096; + const quality = opts.quality ?? 82; + const wantAvif = opts.avif !== false; + const wantThumb = opts.thumbnail !== false; + + // Read metadata up-front so we know what we're dealing with. + const meta = await sharp(input, { failOn: "none" }).metadata(); + const inputFormat = (meta.format || "unknown").toLowerCase(); + const hasAlpha = !!meta.hasAlpha; + const inputWidth = meta.width || 0; + const inputHeight = meta.height || 0; + + // Build a pipeline that's rotation-aware (EXIF) and resized when needed. + function pipeline() { + let p = sharp(input, { failOn: "none" }).rotate(); + if (inputWidth > maxSide || inputHeight > maxSide) { + p = p.resize({ width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true }); + } + return p; + } + + const out: OptimizedImage = { + width: inputWidth, + height: inputHeight, + format: inputFormat, + hasAlpha, + webp: Buffer.alloc(0), + bytes: { original: input.byteLength, webp: 0 }, + }; + + // ── WebP (always; universal modern fallback) ────────────────────────────── + out.webp = await pipeline() + .webp({ quality, effort: 4, alphaQuality: hasAlpha ? quality : undefined }) + .toBuffer(); + out.bytes.webp = out.webp.byteLength; + + // ── AVIF (better compression, slower encode) ────────────────────────────── + if (wantAvif) { + try { + out.avif = await pipeline() + .avif({ quality, effort: 4 }) + .toBuffer(); + out.bytes.avif = out.avif.byteLength; + } catch { + // libvips without AVIF support — skip, WebP covers us + } + } + + // ── Preserve original format for legacy fallback ────────────────────────── + if (hasAlpha) { + out.png = await pipeline() + .png({ compressionLevel: 9, adaptiveFiltering: true }) + .toBuffer(); + out.bytes.png = out.png.byteLength; + } else { + out.jpeg = await pipeline() + .jpeg({ quality, progressive: true, mozjpeg: true }) + .toBuffer(); + out.bytes.jpeg = out.jpeg.byteLength; + } + + // ── Thumbnail (320px wide JPEG, no transparency — feed/card use only) ───── + if (wantThumb) { + out.thumbnail = await sharp(input, { failOn: "none" }) + .rotate() + .resize({ width: 320, height: 240, fit: "cover", position: "centre" }) + .jpeg({ quality: 80, progressive: true, mozjpeg: true }) + .toBuffer(); + out.bytes.thumbnail = out.thumbnail.byteLength; + } + + return out; +} + +/** Convenience: only need a single optimized buffer in best modern format. */ +export async function optimizeToWebp(input: Buffer, opts: OptimizeOptions = {}) { + const r = await optimizeImage(input, { ...opts, avif: false, thumbnail: false }); + return { buffer: r.webp, width: r.width, height: r.height, bytes: r.bytes }; +}