fix: preserve approved assets when adding project media
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import { copyFile, lstat, mkdir, open, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, extname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
@@ -7,6 +9,7 @@ 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';
|
||||
|
||||
@@ -161,14 +164,124 @@ const approvalsMatch = async (path, expected) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002');
|
||||
};
|
||||
|
||||
const publish = async ({ candidates, stagingRoot, publishRoot, manifestPath }) => {
|
||||
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 loadManifestAssets = async (manifestPath) => {
|
||||
const manifest = await readJson(manifestPath, '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;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && [rule, 'ASSET-HASH-001'].includes(error.message)) throw error;
|
||||
fail(rule);
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCurrentPublication = async (publishRoot, assets) => {
|
||||
const safePublishRoot = assertSafeOutputRoot(publishRoot, 'ASSET-PUBLISH-001');
|
||||
let rootStats;
|
||||
try { rootStats = await lstat(safePublishRoot); } 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(safePublishRoot, { 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');
|
||||
}
|
||||
for (const asset of assets) {
|
||||
await readVerifiedRegularFile(join(safePublishRoot, asset.fileName), asset.sha256, 'ASSET-EXACT-001');
|
||||
}
|
||||
return safePublishRoot;
|
||||
};
|
||||
|
||||
const publish = async ({ candidates, recipes, stagingRoot, publishRoot, manifestPath }) => {
|
||||
if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) {
|
||||
fail('ASSET-RIGHTS-001');
|
||||
}
|
||||
const safePublishRoot = await resetDirectory(publishRoot, 'ASSET-PUBLISH-001');
|
||||
const baseAssets = await loadManifestAssets(manifestPath);
|
||||
const safePublishRoot = await verifyCurrentPublication(publishRoot, baseAssets);
|
||||
const managedIds = new Set(recipes.map(({ id }) => id));
|
||||
const mergedAssets = mergeAssetRecords(baseAssets, candidates, managedIds);
|
||||
for (const item of candidates) {
|
||||
validateManifestRecord(item);
|
||||
await readVerifiedRegularFile(join(stagingRoot, item.fileName), item.sha256, 'ASSET-EXACT-001');
|
||||
}
|
||||
|
||||
const mergedNames = new Set(mergedAssets.map(({ fileName }) => fileName));
|
||||
const obsoleteNames = baseAssets
|
||||
.filter(({ id, fileName }) => managedIds.has(id) && !mergedNames.has(fileName))
|
||||
.map(({ fileName }) => fileName);
|
||||
for (const item of candidates) await copyFile(join(stagingRoot, item.fileName), join(safePublishRoot, item.fileName));
|
||||
for (const item of candidates) {
|
||||
await readVerifiedRegularFile(join(safePublishRoot, item.fileName), item.sha256, 'ASSET-EXACT-001');
|
||||
}
|
||||
for (const fileName of obsoleteNames) await unlink(join(safePublishRoot, fileName));
|
||||
await verifyCurrentPublication(safePublishRoot, mergedAssets);
|
||||
await mkdir(dirname(manifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, `${JSON.stringify({ assets: candidates }, null, 2)}\n`, 'utf8');
|
||||
await writeFile(manifestPath, `${JSON.stringify({ assets: mergedAssets }, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
export const prepareAssets = async (options) => {
|
||||
@@ -199,7 +312,10 @@ export const prepareAssets = async (options) => {
|
||||
if (options.approvalRegisterPath !== undefined) {
|
||||
if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001');
|
||||
await approvalsMatch(options.approvalRegisterPath, approval);
|
||||
await publish({ candidates: finalCandidates, stagingRoot, publishRoot: options.publishRoot, manifestPath: options.manifestPath });
|
||||
await publish({
|
||||
candidates: finalCandidates, recipes, stagingRoot,
|
||||
publishRoot: options.publishRoot, manifestPath: options.manifestPath,
|
||||
});
|
||||
approvalStatus = 'approved';
|
||||
}
|
||||
return { candidates: finalCandidates, approvalStatus, reportDir };
|
||||
@@ -207,23 +323,13 @@ export const prepareAssets = async (options) => {
|
||||
|
||||
export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => {
|
||||
if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001');
|
||||
const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001');
|
||||
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
|
||||
const expected = manifest.assets.map(({ fileName }) => fileName).sort();
|
||||
const actual = (await readdir(publishRoot, { withFileTypes: true }))
|
||||
.map((entry) => {
|
||||
if (!entry.isFile()) fail('ASSET-EXACT-001');
|
||||
return entry.name;
|
||||
}).sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-EXACT-001');
|
||||
const assets = await loadManifestAssets(manifestPath);
|
||||
const safePublishRoot = await verifyCurrentPublication(publishRoot, assets);
|
||||
const denylist = await loadDenylist(denylistPath);
|
||||
for (const item of manifest.assets) {
|
||||
if (!item || typeof item.fileName !== 'string' || !/^project-[a-z0-9-]+\.(webp|png|webm|mp4)$/u.test(item.fileName)) fail('ASSET-MANIFEST-001');
|
||||
const path = join(publishRoot, item.fileName);
|
||||
if ((await stat(path)).nlink !== 1) fail('ASSET-EXACT-001');
|
||||
if (await hashFile(path) !== item.sha256) fail('ASSET-HASH-001');
|
||||
for (const item of assets) {
|
||||
const path = join(safePublishRoot, item.fileName);
|
||||
if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true });
|
||||
else await scanBinary(path, denylist);
|
||||
else if (item.mediaType.startsWith('video/')) await scanBinary(path, denylist);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user