29 lines
1.1 KiB
TypeScript
29 lines
1.1 KiB
TypeScript
import type { PublicAssetManifest } from './types.ts';
|
|
|
|
const syntheticOrigin = 'https://portfolio-assets.invalid';
|
|
|
|
const normalizeAssetBase = (base: string): string => {
|
|
if (
|
|
!base.startsWith('/') ||
|
|
base.startsWith('//') ||
|
|
/[\u0000-\u001f\u007f\\%?#]/u.test(base) ||
|
|
base.split('/').some((segment) => segment === '.' || segment === '..')
|
|
) {
|
|
throw new Error('Asset URL base must be a safe absolute path.');
|
|
}
|
|
const resolved = new URL(base, syntheticOrigin);
|
|
if (resolved.origin !== syntheticOrigin) {
|
|
throw new Error('Asset URL base must remain under the synthetic origin.');
|
|
}
|
|
const normalizedPath = resolved.pathname.replace(/\/+/gu, '/');
|
|
return normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
|
|
};
|
|
|
|
export const assetUrl = (manifest: PublicAssetManifest, assetId: string, base = '/assets/'): string => {
|
|
const asset = manifest.assets.find((candidate) => candidate.id === assetId);
|
|
if (asset === undefined) {
|
|
throw new Error(`Unknown asset ID: ${assetId}`);
|
|
}
|
|
return `${normalizeAssetBase(base)}${encodeURIComponent(asset.fileName)}`;
|
|
};
|