feat: add deterministic cleared project media
This commit is contained in:
91
scripts/lib/asset-recipes.mjs
Normal file
91
scripts/lib/asset-recipes.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user