Feature: configurable PWA install screenshots (per-domain)
Admins can upload custom mobile/desktop screenshots shown in the browser's PWA install dialog, replacing the hardcoded Bulwark ones. Two new config keys (pwaScreenshotMobileUrl/DesktopUrl), upload widgets in the admin Branding tab, a sharp-based /api/pwa-screenshot/[variant] resize route, and manifest.ts picks the custom screenshots when configured. Like the other branding fields, screenshots are per-domain: they are BRANDING_OVERRIDE_KEYS, the manifest and the /api/pwa-screenshot route resolve them from the request host (domain override -> global -> Bulwark default), and the admin Branding tab + upload/delete route handle them in a per-domain scope, mirroring pwaIconUrl/faviconUrl.
This commit is contained in:
@@ -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<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
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/<file>
|
||||
// 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<unknown>('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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user