diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 99e2249a..bfa87c13 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -33,6 +33,8 @@ const TEXT_FIELDS = [ const PWA_IMAGE_FIELDS = [ { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, + { key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' }, + { key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' }, ] as const; const PWA_TEXT_FIELDS = [ diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 60dcead7..11f68b1e 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -26,14 +26,18 @@ const ALLOWED_MIME_TYPES = new Set([ 'image/vnd.microsoft.icon', ]); +type UploadSlot = BrandingOverrideKey; + /** Slots that correspond to branding config keys */ -const VALID_SLOTS = new Set([ +const VALID_SLOTS = new Set([ 'faviconUrl', 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl', + 'pwaScreenshotMobileUrl', + 'pwaScreenshotDesktopUrl', ]); const EXT_BY_MIME: Record = { @@ -131,7 +135,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); } - if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) { + if (!VALID_SLOTS.has(slot as UploadSlot)) { return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 }); } @@ -226,7 +230,7 @@ export async function DELETE(request: NextRequest) { const slot = body.slot; const rawHost = body.host ?? ''; - if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) { + if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) { return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); } diff --git a/app/api/pwa-screenshot/[variant]/route.ts b/app/api/pwa-screenshot/[variant]/route.ts new file mode 100644 index 00000000..a1a83acd --- /dev/null +++ b/app/api/pwa-screenshot/[variant]/route.ts @@ -0,0 +1,104 @@ +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 }); + } +} diff --git a/app/api/setup/branding/route.ts b/app/api/setup/branding/route.ts index 27fb58bb..0d41e7f7 100644 --- a/app/api/setup/branding/route.ts +++ b/app/api/setup/branding/route.ts @@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([ const VALID_SLOTS = new Set([ 'faviconUrl', + 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl', + 'pwaScreenshotMobileUrl', + 'pwaScreenshotDesktopUrl', ]); const EXT_BY_MIME: Record = { diff --git a/app/manifest.ts b/app/manifest.ts index d7046d5e..4e878bdd 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -95,10 +95,25 @@ export default async function manifest(): Promise { background_color: backgroundColor, icons, categories: ["productivity"], - screenshots: [ - { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, - { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, - ], + // Use admin-uploaded screenshots when configured (per-domain override, + // admin/env global; resized on the fly via /api/pwa-screenshot/[variant]); + // otherwise fall back to the built-in Bulwark screenshots from public/. + screenshots: (() => { + const hasMobile = + !!domainOverrides.pwaScreenshotMobileUrl || + sources.pwaScreenshotMobileUrl?.source !== "default"; + const hasDesktop = + !!domainOverrides.pwaScreenshotDesktopUrl || + sources.pwaScreenshotDesktopUrl?.source !== "default"; + return [ + hasMobile + ? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" } + : { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, + hasDesktop + ? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" } + : { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, + ]; + })(), protocol_handlers: [ { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, diff --git a/lib/admin/domain-branding.ts b/lib/admin/domain-branding.ts index d5efe8ec..61de464a 100644 --- a/lib/admin/domain-branding.ts +++ b/lib/admin/domain-branding.ts @@ -15,6 +15,8 @@ export const BRANDING_OVERRIDE_KEYS = [ 'appDescription', 'faviconUrl', 'pwaIconUrl', + 'pwaScreenshotMobileUrl', + 'pwaScreenshotDesktopUrl', 'pwaThemeColor', 'pwaBackgroundColor', 'appLogoLightUrl', @@ -42,6 +44,8 @@ export interface DomainBrandingEntry { appDescription?: string; faviconUrl?: string; pwaIconUrl?: string; + pwaScreenshotMobileUrl?: string; + pwaScreenshotDesktopUrl?: string; pwaThemeColor?: string; pwaBackgroundColor?: string; appLogoLightUrl?: string; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 988c2b4a..4a89ba26 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -142,6 +142,8 @@ export const CONFIG_ENV_MAP: Record