Files
immich-folio/app/api/og/route.tsx
Ralksta 10ec68f493 release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
2026-08-23 23:18:14 +02:00

121 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dynamic OG image generator — renders social share previews.
* Uses next/og (ImageResponse) to create 1200×630 cards.
* Reads accent color from theme config.
*
* GET /api/og?title=Album+Name&subtitle=12+photos
*/
import { ImageResponse } from 'next/og';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
/**
* OG image rendering runs satori + resvg per request and each unique ?title
* misses every cache tier, so this is far more expensive than an image proxy
* hit. It must not inherit RATE_LIMIT_RPM (default 1500) — that turns the
* endpoint into an unauthenticated CPU amplifier.
*/
const OG_RPM = 30;
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { theme } = getConfig();
const { success, resetAt } = checkRateLimit(`og:${ip}`, OG_RPM);
if (!success) {
return new NextResponse('Too many requests', {
status: 429,
headers: {
'Retry-After': String(retryAfterSeconds(resetAt)),
'Cache-Control': 'no-store',
},
});
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { searchParams } = request.nextUrl;
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0a0a0a',
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: '60px',
}}
>
{/* Decorative top line */}
<div
style={{
width: '60px',
height: '2px',
backgroundColor: theme.accent,
marginBottom: '40px',
}}
/>
{/* Title */}
<div
style={{
fontSize: '64px',
fontWeight: 600,
color: '#f0f0f0',
letterSpacing: '0.02em',
textAlign: 'center',
lineHeight: 1.2,
maxWidth: '900px',
}}
>
{title}
</div>
{/* Subtitle */}
{subtitle && (
<div
style={{
fontSize: '24px',
color: '#999999',
fontWeight: 300,
marginTop: '16px',
letterSpacing: '0.08em',
textTransform: 'lowercase',
}}
>
{subtitle}
</div>
)}
{/* Bottom decorative line */}
<div
style={{
width: '60px',
height: '2px',
backgroundColor: theme.accent,
marginTop: '40px',
}}
/>
</div>,
{
width: 1200,
height: 630,
headers: {
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
},
},
);
}