Files
webmail/app/api/og-image/route.ts
T

146 lines
5.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import { configManager } from '@/lib/admin/config-manager';
import { fetchBrandingAsset } from '@/lib/admin/branding-asset';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from '@/lib/admin/domain-branding';
/**
* Link-preview (OpenGraph / Twitter card) image.
*
* Chat clients and social platforms render the URL from <meta property="og:image">
* when someone shares a link to the webmail. 1200x630 is the size those
* platforms crop to, so we render exactly that: the deployment's logo centred on
* the configured PWA background colour, honoring per-domain branding like
* app/manifest.ts and /api/pwa-icon already do.
*
* No text is drawn - rendering text server-side would need fonts present in the
* container, and the bundled lettering logos already carry the wordmark as
* artwork.
*/
// Not exported: Next.js only allows its own route fields (GET, dynamic, ...) as
// exports from a route module and fails the build on anything else.
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
// The logo never fills the whole canvas; platforms crop the edges of a preview
// card at some aspect ratios, so keep the artwork well inside a safe area.
const CONTENT_WIDTH = Math.round(OG_WIDTH * 0.66);
const CONTENT_HEIGHT = Math.round(OG_HEIGHT * 0.55);
// Bundled lettering logos, used when a deployment has no branding configured.
const DEFAULT_LOGO_ON_LIGHT = '/branding/Bulwark_Logo_with_Lettering_Dark_and_Color.png';
const DEFAULT_LOGO_ON_DARK = '/branding/Bulwark_Logo_with_Lettering_White_and_Color.png';
type Rgb = { r: number; g: number; b: number };
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
/** Parse `#rgb` / `#rrggbb`. Returns null for anything else so callers can fall back. */
function parseHexColor(value: string): Rgb | null {
const hex = value.trim().replace(/^#/, '');
if (hex.length === 3) {
const [r, g, b] = hex.split('').map((c) => parseInt(c + c, 16));
return Number.isNaN(r! + g! + b!) ? null : { r: r!, g: g!, b: b! };
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : { r, g, b };
}
return null;
}
/** Perceived brightness (0-255) - decides whether the light or dark logo reads better. */
function isDarkColor({ r, g, b }: Rgb): boolean {
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
}
// Cache rendered cards keyed by (source logo, background) so a branding change
// invalidates the previous render instead of serving stale bytes forever.
const cache = new Map<string, Blob>();
export async function GET(req: NextRequest) {
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
/** Configured value for `key`, ignoring built-in defaults (those are placeholders, not branding). */
const configured = (key: keyof typeof domainOverrides): string => {
const override = domainOverrides[key];
if (typeof override === 'string' && override.length > 0) return override;
const entry = sources[key];
return entry && entry.source !== 'default' ? ((entry.value as string) ?? '') : '';
};
const background = parseHexColor(configured('pwaBackgroundColor')) ?? WHITE;
const dark = isDarkColor(background);
// Prefer the app logo for the matching background, then the other logo
// variant, then the square app icon / favicon, then the bundled wordmark.
const logoUrl =
(dark ? configured('appLogoDarkUrl') : configured('appLogoLightUrl')) ||
(dark ? configured('appLogoLightUrl') : configured('appLogoDarkUrl')) ||
(dark ? configured('loginLogoDarkUrl') : configured('loginLogoLightUrl')) ||
configured('pwaIconUrl') ||
configured('faviconUrl') ||
(dark ? DEFAULT_LOGO_ON_DARK : DEFAULT_LOGO_ON_LIGHT);
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${logoUrl}|${background.r},${background.g},${background.b}`;
try {
if (cache.has(cacheKey)) {
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
}
const sourceBuffer = await fetchBrandingAsset(logoUrl, 'OG image logo');
// 'contain' pads the logo out to exactly CONTENT_WIDTH x CONTENT_HEIGHT with
// transparency, so compositing it centre-gravity keeps the original aspect
// ratio without any arithmetic on the intrinsic size. withoutEnlargement is
// off deliberately: a small favicon should still fill the card.
const logo = await sharp(sourceBuffer)
.resize(CONTENT_WIDTH, CONTENT_HEIGHT, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const card = await sharp({
create: {
width: OG_WIDTH,
height: OG_HEIGHT,
channels: 4,
background: { ...background, alpha: 1 },
},
})
.composite([{ input: logo, gravity: 'center' }])
.png()
.toBuffer();
const ab = new ArrayBuffer(card.byteLength);
new Uint8Array(ab).set(card);
const blob = new Blob([ab], { type: 'image/png' });
cache.set(cacheKey, blob);
return new NextResponse(blob, { headers: pngHeaders });
} catch (err) {
console.error('Failed to generate OG image:', err);
return new NextResponse('Failed to generate OG image', { status: 500 });
}
}