519 lines
24 KiB
JavaScript
519 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from 'node:crypto';
|
|
import { constants } from 'node:fs';
|
|
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
import { basename, dirname, extname, join, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import sharp from 'sharp';
|
|
import { fileTypeFromFile } from 'file-type';
|
|
|
|
import { hashFile } from './lib/hash-file.mjs';
|
|
import { loadPrivateMapping, loadRecipes, readJson, resolvePrivateSource } from './lib/asset-recipes.mjs';
|
|
import { mergeAssetRecords } from './lib/merge-assets.mjs';
|
|
import { loadDenylist, scanBinary, scanImage } from './lib/privacy-scan.mjs';
|
|
import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/video-frames.mjs';
|
|
|
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const fail = (rule) => { throw new Error(rule); };
|
|
const isErrno = (error, code) => typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
|
|
|
const canonicalizePathRole = async (path) => {
|
|
let cursor = resolve(path);
|
|
const suffix = [];
|
|
while (true) {
|
|
try {
|
|
return resolve(await realpath(cursor), ...suffix);
|
|
} catch (error) {
|
|
if (!isErrno(error, 'ENOENT')) fail('ASSET-PATH-001');
|
|
const parent = dirname(cursor);
|
|
if (parent === cursor) fail('ASSET-PATH-001');
|
|
suffix.unshift(basename(cursor));
|
|
cursor = parent;
|
|
}
|
|
}
|
|
};
|
|
|
|
const pathsOverlap = (left, right) =>
|
|
left === right || left.startsWith(`${right}${sep}`) || right.startsWith(`${left}${sep}`);
|
|
|
|
const validatePathRoles = async (options) => {
|
|
const roleNames = [
|
|
'sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir',
|
|
...(options.approvalRegisterPath === undefined ? [] : ['approvalRegisterPath', 'publishRoot', 'manifestPath']),
|
|
];
|
|
const roles = Object.fromEntries(await Promise.all(roleNames.map(async (name) => [
|
|
name,
|
|
await canonicalizePathRole(options[name]),
|
|
])));
|
|
for (const resetRole of ['stagingRoot', 'reportDir']) {
|
|
if (!roles[resetRole].startsWith(`/tmp${sep}`)) fail('ASSET-PATH-001');
|
|
for (const otherRole of roleNames) {
|
|
if (resetRole !== otherRole && pathsOverlap(roles[resetRole], roles[otherRole])) fail('ASSET-PATH-001');
|
|
}
|
|
}
|
|
return roles;
|
|
};
|
|
|
|
const assertSafeOutputRoot = (path, rule) => {
|
|
const resolved = resolve(path);
|
|
const permittedRepositoryOutput = join(repositoryRoot, 'inputs', 'prepared-assets');
|
|
if (resolved !== permittedRepositoryOutput && !resolved.startsWith('/tmp/')) fail(rule);
|
|
return resolved;
|
|
};
|
|
|
|
const resetDirectory = async (path, rule) => {
|
|
const safe = assertSafeOutputRoot(path, rule);
|
|
await rm(safe, { recursive: true, force: true });
|
|
await mkdir(safe, { recursive: true });
|
|
return safe;
|
|
};
|
|
|
|
const sorted = (items) => [...items].sort((left, right) => left.fileName.localeCompare(right.fileName, 'en-US'));
|
|
|
|
const candidate = async ({ id, fileName, path, mediaType, width, height, durationMs, recipe }) => ({
|
|
id,
|
|
fileName,
|
|
sha256: await hashFile(path),
|
|
mediaType,
|
|
width,
|
|
height,
|
|
durationMs,
|
|
rightsHolder: recipe.rightsHolder,
|
|
license: recipe.license,
|
|
attribution: recipe.attribution,
|
|
});
|
|
|
|
const encodeImage = async (source, output, recipe) => {
|
|
let detected;
|
|
try { detected = await fileTypeFromFile(source); } catch { fail('ASSET-TYPE-001'); }
|
|
if (!detected || !['image/jpeg', 'image/png', 'image/webp'].includes(detected.mime)) fail('ASSET-TYPE-001');
|
|
try {
|
|
let pipeline = sharp(source, { failOn: 'error', limitInputPixels: 50_000_000, sequentialRead: true })
|
|
.rotate()
|
|
.resize({ width: recipe.maxWidth, withoutEnlargement: true, fit: 'inside', kernel: sharp.kernel.lanczos3 });
|
|
pipeline = recipe.output.endsWith('.png')
|
|
? pipeline.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
: pipeline.webp({ lossless: true, effort: 6, smartSubsample: false });
|
|
await pipeline.toFile(output);
|
|
} catch {
|
|
fail('ASSET-DECODE-001');
|
|
}
|
|
const details = await scanImage(output, [], { runOcr: false });
|
|
return details;
|
|
};
|
|
|
|
const prepareImage = async ({ source, recipe, stagingRoot, denylist }) => {
|
|
const path = join(stagingRoot, recipe.output);
|
|
const details = await encodeImage(source, path, recipe);
|
|
await scanImage(path, denylist, { runOcr: true });
|
|
return [await candidate({
|
|
id: recipe.id, fileName: recipe.output, path, mediaType: details.mediaType,
|
|
width: details.width, height: details.height, durationMs: null, recipe,
|
|
})];
|
|
};
|
|
|
|
const prepareVideo = async ({ source, recipe, stagingRoot, reportDir, denylist }) => {
|
|
const sourceProbe = await probeVideo(source);
|
|
if (recipe.startSeconds + recipe.durationSeconds > sourceProbe.duration + 0.05) fail('ASSET-VIDEO-RANGE-001');
|
|
const base = recipe.output.slice(0, -'.webm'.length);
|
|
const webmPath = join(stagingRoot, `${base}.webm`);
|
|
const mp4Path = join(stagingRoot, `${base}.mp4`);
|
|
const posterPath = join(stagingRoot, `${base}-poster.webp`);
|
|
await encodeVideoDerivatives({
|
|
source, webmPath, mp4Path, posterPath,
|
|
startSeconds: recipe.startSeconds, durationSeconds: recipe.durationSeconds, maxWidth: recipe.maxWidth,
|
|
});
|
|
const webmProbe = await probeVideo(webmPath, { allowAudio: false });
|
|
const mp4Probe = await probeVideo(mp4Path, { allowAudio: false });
|
|
const frames = await extractVideoFrames({
|
|
source: webmPath,
|
|
durationSeconds: Math.min(webmProbe.duration, recipe.durationSeconds),
|
|
outputDir: join(reportDir, 'video-frames'),
|
|
prefix: recipe.id,
|
|
});
|
|
await scanBinary(webmPath, denylist);
|
|
await scanBinary(mp4Path, denylist);
|
|
await scanImage(posterPath, denylist, { runOcr: true });
|
|
for (const frame of frames) await scanImage(frame, denylist, { runOcr: true });
|
|
const poster = await sharp(posterPath).metadata();
|
|
return [
|
|
await candidate({
|
|
id: recipe.id, fileName: `${base}.webm`, path: webmPath, mediaType: 'video/webm',
|
|
width: webmProbe.width, height: webmProbe.height, durationMs: Math.round(webmProbe.duration * 1000), recipe,
|
|
}),
|
|
await candidate({
|
|
id: `${recipe.id}-mp4`, fileName: `${base}.mp4`, path: mp4Path, mediaType: 'video/mp4',
|
|
width: mp4Probe.width, height: mp4Probe.height, durationMs: Math.round(mp4Probe.duration * 1000), recipe,
|
|
}),
|
|
await candidate({
|
|
id: `${recipe.id}-poster`, fileName: `${base}-poster.webp`, path: posterPath, mediaType: 'image/webp',
|
|
width: poster.width, height: poster.height, durationMs: null, recipe,
|
|
}),
|
|
];
|
|
};
|
|
|
|
const createContactSheet = async (candidates, stagingRoot, reportDir) => {
|
|
const images = candidates.filter(({ mediaType }) => mediaType.startsWith('image/')).map((image) => ({
|
|
id: image.id,
|
|
path: join(stagingRoot, image.fileName),
|
|
}));
|
|
let frameNames = [];
|
|
try { frameNames = (await readdir(join(reportDir, 'video-frames'))).filter((name) => name.endsWith('.png')).sort(); } catch {}
|
|
images.push(...frameNames.map((name, index) => ({
|
|
id: `private-game-loop-frame-${String(index + 1).padStart(2, '0')}`,
|
|
path: join(reportDir, 'video-frames', name),
|
|
})));
|
|
if (images.length === 0) return;
|
|
const imageWidth = 1100;
|
|
const width = 1200;
|
|
const composites = [];
|
|
let y = 30;
|
|
for (const image of images) {
|
|
const thumb = await sharp(image.path)
|
|
.resize({ width: imageWidth, height: 1400, fit: 'inside', withoutEnlargement: false })
|
|
.png()
|
|
.toBuffer();
|
|
const metadata = await sharp(thumb).metadata();
|
|
const left = Math.round((width - (metadata.width ?? imageWidth)) / 2);
|
|
composites.push({ input: thumb, left, top: y });
|
|
const safeLabel = image.id.replace(/[^a-z0-9-]/gu, '');
|
|
const label = Buffer.from(`<svg width="1100" height="54" xmlns="http://www.w3.org/2000/svg"><rect width="1100" height="54" fill="#11131a"/><text x="16" y="36" font-size="24" font-family="sans-serif" fill="#f4f2f7">${safeLabel}</text></svg>`);
|
|
y += metadata.height ?? 0;
|
|
composites.push({ input: label, left: 50, top: y + 8 });
|
|
y += 86;
|
|
}
|
|
await sharp({ create: { width, height: y + 20, channels: 3, background: '#070910' } })
|
|
.composite(composites)
|
|
.webp({ lossless: true, effort: 6 })
|
|
.toFile(join(reportDir, 'contact-sheet.webp'));
|
|
};
|
|
|
|
const approvalProjection = (recipes, candidates) => ({
|
|
schema: 'portfolio-asset-approvals/v1',
|
|
assets: recipes.map(({ id }) => ({
|
|
id,
|
|
files: sorted(candidates.filter((item) => item.id === id || item.id.startsWith(`${id}-`)))
|
|
.map(({ fileName, sha256 }) => ({ fileName, sha256 })),
|
|
})).sort((left, right) => left.id.localeCompare(right.id, 'en-US')),
|
|
});
|
|
|
|
const approvalsMatch = async (path, expected) => {
|
|
const actual = await readJson(path, 'ASSET-APPROVAL-001');
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002');
|
|
};
|
|
|
|
const manifestKeys = [
|
|
'id', 'fileName', 'sha256', 'mediaType', 'width', 'height', 'durationMs',
|
|
'rightsHolder', 'license', 'attribution',
|
|
];
|
|
const mediaExtension = new Map([
|
|
['image/avif', 'avif'], ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/svg+xml', 'svg'],
|
|
['image/webp', 'webp'], ['video/mp4', 'mp4'], ['video/webm', 'webm'],
|
|
['audio/ogg', 'opus'], ['audio/mpeg', 'mp3'],
|
|
]);
|
|
const isPositiveIntegerOrNull = (value) => value === null || (Number.isInteger(value) && value > 0);
|
|
const hasUnpairedSurrogate = (value) => {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const codeUnit = value.charCodeAt(index);
|
|
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
const next = value.charCodeAt(index + 1);
|
|
if (next < 0xdc00 || next > 0xdfff) return true;
|
|
index += 1;
|
|
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const isVisibleString = (value) => typeof value === 'string' && value.trim().length > 0 &&
|
|
value === value.normalize('NFC') && !hasUnpairedSurrogate(value) && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
|
|
const validateManifestRecord = (asset) => {
|
|
if (!asset || typeof asset !== 'object' || Array.isArray(asset) ||
|
|
Object.keys(asset).length !== manifestKeys.length || manifestKeys.some((key) => !(key in asset)) ||
|
|
typeof asset.id !== 'string' || typeof asset.fileName !== 'string' || typeof asset.sha256 !== 'string' ||
|
|
!mediaExtension.has(asset.mediaType) || !asset.fileName.endsWith(`.${mediaExtension.get(asset.mediaType)}`) ||
|
|
!isPositiveIntegerOrNull(asset.width) || !isPositiveIntegerOrNull(asset.height) ||
|
|
!isPositiveIntegerOrNull(asset.durationMs) ||
|
|
!isVisibleString(asset.rightsHolder) || !isVisibleString(asset.license) || !isVisibleString(asset.attribution)) {
|
|
fail('ASSET-MANIFEST-001');
|
|
}
|
|
const isImage = asset.mediaType.startsWith('image/');
|
|
const isVideo = asset.mediaType.startsWith('video/');
|
|
const isAudio = asset.mediaType.startsWith('audio/');
|
|
if ((isImage && (asset.width === null || asset.height === null || asset.durationMs !== null)) ||
|
|
(isVideo && (asset.width === null || asset.height === null || asset.durationMs === null)) ||
|
|
(isAudio && (asset.width !== null || asset.height !== null || asset.durationMs === null))) {
|
|
fail('ASSET-MANIFEST-001');
|
|
}
|
|
};
|
|
|
|
const fileIdentity = (details) => ({ dev: details.dev, ino: details.ino });
|
|
const sameIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
|
|
const manifestAssetsFromBytes = (bytes) => {
|
|
let manifest;
|
|
try { manifest = JSON.parse(bytes.toString('utf8')); } catch { fail('ASSET-MANIFEST-001'); }
|
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) ||
|
|
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
|
|
for (const asset of manifest.assets) validateManifestRecord(asset);
|
|
try {
|
|
mergeAssetRecords(manifest.assets, [], new Set());
|
|
} catch {
|
|
fail('ASSET-MANIFEST-001');
|
|
}
|
|
return manifest.assets;
|
|
};
|
|
|
|
const readVerifiedRegularFile = async (path, expectedHash, rule) => {
|
|
let handle;
|
|
try {
|
|
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
const details = await handle.stat();
|
|
if (!details.isFile() || details.nlink !== 1) fail(rule);
|
|
const bytes = await handle.readFile();
|
|
if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) fail('ASSET-HASH-001');
|
|
return { bytes, identity: fileIdentity(details) };
|
|
} catch (error) {
|
|
if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error;
|
|
fail(rule);
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
};
|
|
|
|
const readManifestSnapshot = async (manifestPath) => {
|
|
let handle;
|
|
try {
|
|
handle = await open(manifestPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
const details = await handle.stat();
|
|
if (!details.isFile() || details.nlink !== 1) fail('ASSET-MANIFEST-001');
|
|
const bytes = await handle.readFile();
|
|
return { assets: manifestAssetsFromBytes(bytes), bytes, identity: fileIdentity(details) };
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'ASSET-MANIFEST-001') throw error;
|
|
fail('ASSET-MANIFEST-001');
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
};
|
|
|
|
const verifyPublicationDirectory = async (publishRoot, assets) => {
|
|
let rootStats;
|
|
try { rootStats = await lstat(publishRoot); } catch { fail('ASSET-EXACT-001'); }
|
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) fail('ASSET-EXACT-001');
|
|
const expectedNames = new Set(assets.map(({ fileName }) => fileName));
|
|
const entries = await readdir(publishRoot, { withFileTypes: true });
|
|
if (entries.length !== expectedNames.size) fail('ASSET-EXACT-001');
|
|
for (const entry of entries) {
|
|
if (!expectedNames.has(entry.name) || entry.isSymbolicLink() || !entry.isFile()) fail('ASSET-EXACT-001');
|
|
}
|
|
const files = new Map();
|
|
for (const asset of assets) {
|
|
const snapshot = await readVerifiedRegularFile(join(publishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001');
|
|
files.set(asset.fileName, snapshot);
|
|
}
|
|
return { rootIdentity: fileIdentity(rootStats), files };
|
|
};
|
|
|
|
const verifyCurrentPublication = async (publishRoot, assets) => {
|
|
const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001');
|
|
return { safePublishRoot, ...await verifyPublicationDirectory(safePublishRoot, assets) };
|
|
};
|
|
|
|
const managedCoordinatesForRecipes = (recipes) => recipes.flatMap((recipe) => {
|
|
if (recipe.kind === 'image') return [{ id: recipe.id, fileName: recipe.output }];
|
|
if (recipe.kind !== 'video' || !recipe.output.endsWith('.webm')) fail('ASSET-OWNERSHIP-001');
|
|
const base = recipe.output.slice(0, -'.webm'.length);
|
|
return [
|
|
{ id: recipe.id, fileName: `${base}.webm` },
|
|
{ id: `${recipe.id}-mp4`, fileName: `${base}.mp4` },
|
|
{ id: `${recipe.id}-poster`, fileName: `${base}-poster.webp` },
|
|
];
|
|
});
|
|
|
|
const trustedManagedIds = (recipes, candidates) => {
|
|
const expected = managedCoordinatesForRecipes(recipes);
|
|
const expectedCoordinates = expected.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort();
|
|
const actualCoordinates = candidates.map(({ id, fileName }) => `${id}\u0000${fileName}`).sort();
|
|
if (JSON.stringify(actualCoordinates) !== JSON.stringify(expectedCoordinates)) fail('ASSET-OWNERSHIP-001');
|
|
return new Set(expected.map(({ id }) => id));
|
|
};
|
|
|
|
export const publishPreparedAssets = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => {
|
|
if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) {
|
|
fail('ASSET-RIGHTS-001');
|
|
}
|
|
const manifestSnapshot = await readManifestSnapshot(manifestPath);
|
|
const baseAssets = manifestSnapshot.assets;
|
|
const currentPublication = await verifyCurrentPublication(publishRoot, baseAssets);
|
|
const { safePublishRoot } = currentPublication;
|
|
const managedIds = trustedManagedIds(recipes, candidates);
|
|
const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds);
|
|
const candidateSnapshots = new Map();
|
|
for (const item of candidates) {
|
|
validateManifestRecord(item);
|
|
const path = join(stagingRoot, item.fileName);
|
|
candidateSnapshots.set(item.fileName, {
|
|
path,
|
|
expectedHash: item.sha256,
|
|
...await readVerifiedRegularFile(path, item.sha256, 'ASSET-EXACT-001'),
|
|
});
|
|
}
|
|
|
|
let publicationTransactionRoot;
|
|
let manifestTransactionRoot;
|
|
let preserveRecoveryState = false;
|
|
try {
|
|
publicationTransactionRoot = await mkdtemp(join(dirname(safePublishRoot), '.asset-publication-'));
|
|
const nextPublication = join(publicationTransactionRoot, 'next');
|
|
const previousPublication = join(publicationTransactionRoot, 'previous');
|
|
await mkdir(nextPublication);
|
|
for (const asset of mergedAssets) {
|
|
const snapshot = candidateSnapshots.get(asset.fileName) ?? currentPublication.files.get(asset.fileName);
|
|
if (snapshot === undefined) fail('ASSET-PUBLISH-001');
|
|
await writeFile(join(nextPublication, asset.fileName), snapshot.bytes, { flag: 'wx', mode: 0o644 });
|
|
}
|
|
await verifyPublicationDirectory(nextPublication, mergedAssets);
|
|
|
|
manifestTransactionRoot = await mkdtemp(join(dirname(manifestPath), '.asset-manifest-'));
|
|
const nextManifest = join(manifestTransactionRoot, 'next-manifest.json');
|
|
const previousManifest = join(manifestTransactionRoot, 'previous-manifest.json');
|
|
await writeFile(nextManifest, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, { flag: 'wx', mode: 0o644 });
|
|
|
|
for (const snapshot of candidateSnapshots.values()) {
|
|
const current = await readVerifiedRegularFile(snapshot.path, snapshot.expectedHash, 'ASSET-EXACT-001');
|
|
if (!sameIdentity(current.identity, snapshot.identity)) fail('ASSET-EXACT-001');
|
|
}
|
|
const protectedPublication = await verifyCurrentPublication(safePublishRoot, baseAssets);
|
|
if (!sameIdentity(protectedPublication.rootIdentity, currentPublication.rootIdentity)) fail('ASSET-EXACT-001');
|
|
const protectedManifest = await readManifestSnapshot(manifestPath);
|
|
if (!sameIdentity(protectedManifest.identity, manifestSnapshot.identity) ||
|
|
!protectedManifest.bytes.equals(manifestSnapshot.bytes)) fail('ASSET-MANIFEST-001');
|
|
|
|
let publicationBackedUp = false;
|
|
let publicationInstalled = false;
|
|
let manifestBackedUp = false;
|
|
let manifestInstalled = false;
|
|
try {
|
|
await rename(safePublishRoot, previousPublication);
|
|
publicationBackedUp = true;
|
|
await rename(nextPublication, safePublishRoot);
|
|
publicationInstalled = true;
|
|
await rename(manifestPath, previousManifest);
|
|
manifestBackedUp = true;
|
|
await rename(nextManifest, manifestPath);
|
|
manifestInstalled = true;
|
|
const installedManifest = await readManifestSnapshot(manifestPath);
|
|
if (JSON.stringify(installedManifest.assets) !== JSON.stringify(mergedAssets)) fail('ASSET-PUBLISH-001');
|
|
await verifyCurrentPublication(safePublishRoot, mergedAssets);
|
|
} catch {
|
|
try {
|
|
if (manifestInstalled) await rename(manifestPath, nextManifest);
|
|
if (manifestBackedUp) await rename(previousManifest, manifestPath);
|
|
if (publicationInstalled) await rename(safePublishRoot, nextPublication);
|
|
if (publicationBackedUp) await rename(previousPublication, safePublishRoot);
|
|
} catch {
|
|
preserveRecoveryState = true;
|
|
fail('ASSET-PUBLISH-ROLLBACK-001');
|
|
}
|
|
fail('ASSET-PUBLISH-001');
|
|
}
|
|
} finally {
|
|
if (!preserveRecoveryState) {
|
|
if (manifestTransactionRoot !== undefined) await rm(manifestTransactionRoot, { recursive: true, force: true });
|
|
if (publicationTransactionRoot !== undefined) await rm(publicationTransactionRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
};
|
|
|
|
export const prepareAssets = async (options) => {
|
|
const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir'];
|
|
if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001');
|
|
if (options.approvalRegisterPath !== undefined &&
|
|
!['approvalRegisterPath', 'publishRoot', 'manifestPath'].every((key) => typeof options[key] === 'string')) {
|
|
fail('ASSET-ARGUMENT-001');
|
|
}
|
|
const paths = await validatePathRoles(options);
|
|
const recipes = await loadRecipes(paths.recipesPath);
|
|
const mapping = await loadPrivateMapping(paths.mappingPath, recipes);
|
|
const denylist = await loadDenylist(paths.denylistPath);
|
|
const stagingRoot = await resetDirectory(paths.stagingRoot, 'ASSET-STAGING-001');
|
|
const reportDir = await resetDirectory(paths.reportDir, 'ASSET-REPORT-001');
|
|
const candidates = [];
|
|
for (const recipe of recipes) {
|
|
const source = await resolvePrivateSource(paths.sourceRoot, mapping[recipe.id]);
|
|
const prepared = recipe.kind === 'image'
|
|
? await prepareImage({ source, recipe, stagingRoot, denylist })
|
|
: await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist });
|
|
candidates.push(...prepared);
|
|
}
|
|
const finalCandidates = sorted(candidates);
|
|
const approval = approvalProjection(recipes, finalCandidates);
|
|
await writeFile(join(reportDir, 'candidate-approval.json'), `${JSON.stringify(approval, null, 2)}\n`, 'utf8');
|
|
await writeFile(join(reportDir, 'scan-report.json'), `${JSON.stringify({
|
|
schema: 'portfolio-private-scan-report/v1', automatedChecks: 'passed', publication: 'pending-manual-approval',
|
|
assets: finalCandidates.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })),
|
|
}, null, 2)}\n`, 'utf8');
|
|
await createContactSheet(finalCandidates, stagingRoot, reportDir);
|
|
let approvalStatus = 'pending';
|
|
if (options.approvalRegisterPath !== undefined) {
|
|
await approvalsMatch(paths.approvalRegisterPath, approval);
|
|
await publishPreparedAssets({
|
|
candidates: finalCandidates, recipes, stagingRoot,
|
|
publishRoot: paths.publishRoot, manifestPath: paths.manifestPath,
|
|
});
|
|
approvalStatus = 'approved';
|
|
}
|
|
return { candidates: finalCandidates, approvalStatus, reportDir };
|
|
};
|
|
|
|
export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => {
|
|
if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001');
|
|
const { assets } = await readManifestSnapshot(manifestPath);
|
|
const { safePublishRoot } = await verifyCurrentPublication(publishRoot, assets);
|
|
const denylist = await loadDenylist(denylistPath);
|
|
for (const item of assets) {
|
|
const path = join(safePublishRoot, item.fileName);
|
|
if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true });
|
|
else if (item.mediaType.startsWith('video/')) await scanBinary(path, denylist);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const parseArgs = (argv) => {
|
|
const values = {};
|
|
let verify = false;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const key = argv[index];
|
|
if (key === '--verify') { verify = true; continue; }
|
|
if (!key?.startsWith('--') || index + 1 >= argv.length) fail('ASSET-ARGUMENT-001');
|
|
values[key.slice(2)] = argv[index + 1];
|
|
index += 1;
|
|
}
|
|
return { verify, values };
|
|
};
|
|
|
|
const runCli = async () => {
|
|
const { verify, values } = parseArgs(process.argv.slice(2));
|
|
if (verify) {
|
|
await verifyPreparedAssets({ publishRoot: values['publish-root'], manifestPath: values.manifest, denylistPath: values.denylist });
|
|
return;
|
|
}
|
|
await prepareAssets({
|
|
sourceRoot: values['source-root'], mappingPath: values.mapping, denylistPath: values.denylist,
|
|
recipesPath: values.recipes, stagingRoot: values['staging-root'], reportDir: values['report-dir'],
|
|
approvalRegisterPath: values.approvals, publishRoot: values['publish-root'], manifestPath: values.manifest,
|
|
});
|
|
};
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
runCli().catch((error) => {
|
|
const message = error instanceof Error && /^[A-Z]+(?:-[A-Z]+)*-\d{3}(?::[a-z0-9-]+)?$/u.test(error.message)
|
|
? error.message
|
|
: 'ASSET-UNEXPECTED-001';
|
|
process.stderr.write(`${message}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|