feat: add deterministic cleared project media

This commit is contained in:
2026-09-02 06:38:40 +03:00
parent eb672bd664
commit dfd56f0a48
11 changed files with 2954 additions and 0 deletions

View 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);