images: unified server-side optimization pipeline + /api/upload/image

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
<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png} with year-long
immutable caching. Returns dimensions + all derivative URLs + a
primaryUrl convenience field for simple <img src=> 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) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-29 15:43:04 +00:00
parent 976c979e54
commit e24d78cd15
2 changed files with 258 additions and 0 deletions

View File

@@ -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
* /<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png}
* and returns the public URLs + dimensions so the client can either
* embed an <img srcset> 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<string | null> {
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<string, string | null> = {};
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 <picture>; this URL is the
// safe-default <img src=>.
primaryUrl: urls.webp,
});
}