Files
webmail/lib/admin/branding-asset.ts
T

68 lines
2.5 KiB
TypeScript

/**
* Loading of branding image sources for server-side image generation.
*
* A branding URL can point at three different places, and every route that
* renders a branded image (PWA icon, PWA screenshot, OG image) has to resolve
* all three the same way.
*/
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { getConfigDir } from '@/lib/admin/paths';
import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
/** Admin-uploaded assets are served from here but stored under getConfigDir()/branding/. */
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
const REMOTE_TIMEOUT_MS = 10_000;
const MAX_REDIRECTS = 5;
/**
* Remote branding URLs come from admin config, but they are still fetched by
* this process and rendered by public routes, so they go through the
* rebinding-safe fetch: only public addresses, every redirect hop
* re-validated. Private-network logos should be uploaded via the admin
* branding upload instead of linked.
*/
async function fetchRemoteAsset(url: string, label: string): Promise<Buffer> {
let currentUrl = url;
let res: PublicFetchResponse | undefined;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
res = await fetchPublicUrl(currentUrl, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) });
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
if (!location) break;
await res.body?.cancel().catch(() => {});
currentUrl = new URL(location, currentUrl).toString();
continue;
}
break;
}
if (!res || !res.ok) {
throw new Error(`Failed to fetch ${label}: ${res?.status ?? 'too many redirects'}`);
}
return Buffer.from(await res.arrayBuffer());
}
/**
* Read the bytes behind a branding URL: a remote http(s) URL, an
* admin-uploaded asset, or a path relative to public/.
*
* `label` only shapes the error message for remote fetch failures.
*/
export async function fetchBrandingAsset(url: string, label = 'branding asset'): Promise<Buffer> {
// Absolute URL (http/https)
if (url.startsWith('http://') || url.startsWith('https://')) {
return fetchRemoteAsset(url, label);
}
if (url.startsWith(ADMIN_BRANDING_PREFIX)) {
const filename = path.basename(url.slice(ADMIN_BRANDING_PREFIX.length));
return readFile(path.join(getConfigDir(), 'branding', filename));
}
// Path relative to public/ directory
const publicPath = path.join(process.cwd(), 'public', url.replace(/^\//, ''));
return readFile(publicPath);
}