import { readFile, realpath, stat } from 'node:fs/promises'; import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'; const idPattern = /^[a-z][a-z0-9-]{0,63}$/u; const outputPattern = /^project-[a-z0-9][a-z0-9-]*\.(webp|png|webm)$/u; const fail = (rule) => { throw new Error(rule); }; const exactKeys = (record, allowed, rule) => { if (Object.keys(record).some((key) => !allowed.includes(key))) fail(rule); }; export const readJson = async (path, rule = 'ASSET-JSON-001') => { try { return JSON.parse(await readFile(path, 'utf8')); } catch { fail(rule); } }; export const loadRecipes = async (path) => { const input = await readJson(path, 'ASSET-RECIPE-001'); if (!input || typeof input !== 'object' || input.schema !== 'portfolio-asset-recipes/v1' || !Array.isArray(input.assets)) { fail('ASSET-RECIPE-001'); } exactKeys(input, ['schema', 'assets'], 'ASSET-RECIPE-001'); const seenIds = new Set(); const seenOutputs = new Set(); const assets = input.assets.map((asset) => { if (!asset || typeof asset !== 'object') fail('ASSET-RECIPE-001'); exactKeys(asset, [ 'id', 'kind', 'output', 'maxWidth', 'startSeconds', 'durationSeconds', 'rightsHolder', 'license', 'attribution', ], 'ASSET-RECIPE-001'); if (!idPattern.test(asset.id) || !['image', 'video'].includes(asset.kind) || !outputPattern.test(asset.output)) { fail('ASSET-RECIPE-001'); } if (asset.kind === 'image' && !/\.(webp|png)$/u.test(asset.output)) fail('ASSET-RECIPE-001'); if (asset.kind === 'video' && !asset.output.endsWith('.webm')) fail('ASSET-RECIPE-001'); if (!Number.isInteger(asset.maxWidth) || asset.maxWidth < 64 || asset.maxWidth > 4096) fail('ASSET-RECIPE-001'); if (asset.kind === 'video' && ( typeof asset.startSeconds !== 'number' || asset.startSeconds < 0 || typeof asset.durationSeconds !== 'number' || asset.durationSeconds <= 0 || asset.durationSeconds > 30 )) fail('ASSET-RECIPE-001'); for (const field of ['rightsHolder', 'license', 'attribution']) { if (typeof asset[field] !== 'string' || asset[field].trim().length === 0 || /[\u0000-\u001f\u007f]/u.test(asset[field])) { fail('ASSET-RECIPE-001'); } } if (seenIds.has(asset.id) || seenOutputs.has(asset.output.toLowerCase())) fail('ASSET-RECIPE-002'); seenIds.add(asset.id); seenOutputs.add(asset.output.toLowerCase()); return Object.freeze({ ...asset }); }); return Object.freeze(assets); }; export const loadPrivateMapping = async (path, recipes) => { const input = await readJson(path, 'ASSET-MAPPING-001'); if (!input || typeof input !== 'object' || input.schema !== 'portfolio-private-source-map/v1' || !input.sources || typeof input.sources !== 'object' || Array.isArray(input.sources)) fail('ASSET-MAPPING-001'); exactKeys(input, ['schema', 'sources'], 'ASSET-MAPPING-001'); const ids = recipes.map(({ id }) => id).sort(); if (JSON.stringify(Object.keys(input.sources).sort()) !== JSON.stringify(ids)) fail('ASSET-MAPPING-002'); for (const value of Object.values(input.sources)) { if (typeof value !== 'string' || value.length === 0 || isAbsolute(value) || value.includes('\\') || value.split('/').some((part) => part === '' || part === '.' || part === '..')) fail('ASSET-MAPPING-001'); } return input.sources; }; export const resolvePrivateSource = async (sourceRoot, mappedPath) => { const root = await realpath(sourceRoot); const candidate = resolve(root, mappedPath); const rel = relative(root, candidate); if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) fail('ASSET-SOURCE-001'); let resolved; try { resolved = await realpath(candidate); } catch { fail('ASSET-SOURCE-001'); } const resolvedRel = relative(root, resolved); if (resolvedRel.startsWith(`..${sep}`) || resolvedRel === '..' || isAbsolute(resolvedRel)) fail('ASSET-SOURCE-001'); const info = await stat(resolved); if (!info.isFile() || info.size <= 0 || info.size > 100 * 1024 * 1024) fail('ASSET-SOURCE-002'); return resolved; }; export const publicFileNameFor = (recipe) => basename(recipe.output); export const publicExtensionFor = (recipe) => extname(recipe.output).slice(1);