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:
119
src/app/api/upload/image/route.ts
Normal file
119
src/app/api/upload/image/route.ts
Normal 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  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,
|
||||
});
|
||||
}
|
||||
139
src/lib/images/optimize.ts
Normal file
139
src/lib/images/optimize.ts
Normal file
@@ -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<OptimizedImage> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user