import { NextRequest, NextResponse } from 'next/server'; import sharp from 'sharp'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; import { getConfigDir } from '@/lib/admin/paths'; import { matchDomainBranding, parseDomainBranding, pickRequestHost, } from '@/lib/admin/domain-branding'; /** * Variant → target output size + admin config key. * Matches the sizes declared in app/manifest.ts so the rendered PNG fits * the slot the manifest tells the browser about. */ const VARIANTS = { mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const }, desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const }, } as const; type Variant = keyof typeof VARIANTS; // Cache resized images keyed by (variant, source URL). const cache = new Map(); async function fetchSourceImage(iconUrl: string): Promise { if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) { const res = await fetch(iconUrl); if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`); return Buffer.from(await res.arrayBuffer()); } // Admin-uploaded branding asset: served from /api/admin/branding/ // but stored on disk under getConfigDir()/branding/. const ADMIN_BRANDING_PREFIX = '/api/admin/branding/'; if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) { const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length)); return readFile(path.join(getConfigDir(), 'branding', filename)); } // Path relative to public/ directory const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); return readFile(publicPath); } export async function GET( req: NextRequest, { params }: { params: Promise<{ variant: string }> }, ) { const { variant: variantParam } = await params; if (!(variantParam in VARIANTS)) { return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 }); } const { width, height, configKey } = VARIANTS[variantParam as Variant]; await configManager.ensureLoaded(); const host = pickRequestHost(req); const domainOverrides = matchDomainBranding( host, parseDomainBranding(configManager.get('domainBranding', [])), ); const sources = configManager.getAllWithSources(); const sourceEntry = sources[configKey]; const screenshotUrl = domainOverrides[configKey] || (sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined); if (!screenshotUrl) { return new NextResponse('No PWA screenshot configured', { status: 404 }); } const pngHeaders = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400', Vary: 'Host, X-Forwarded-Host', }; const cacheKey = `${variantParam}|${screenshotUrl}`; try { if (cache.has(cacheKey)) { return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders }); } const sourceBuffer = await fetchSourceImage(screenshotUrl); // 'cover' fills the target box without letterboxing - screenshots benefit // more from cropping than from a transparent frame around them. Users get // a hint about the recommended aspect ratio in the admin UI. const resized = await sharp(sourceBuffer) .resize(width, height, { fit: 'cover', position: 'center' }) .png() .toBuffer(); const ab = new ArrayBuffer(resized.byteLength); new Uint8Array(ab).set(resized); 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 PWA screenshot:', err); return new NextResponse('Failed to generate screenshot', { status: 500 }); } }