Files

69 lines
3.8 KiB
JavaScript

import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readdir, readFile, stat } from 'node:fs/promises';
import { extname, join, relative, sep } from 'node:path';
import { allowedUploadExtensions, maxSourceImageBytes } from './image-policy.mjs';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const [intakeSchema, sitesSchema, siteSchema, validIntake, invalidIntake, sites] = await Promise.all([
readJson('schemas/intake.v1.schema.json'), readJson('schemas/sites.schema.json'), readJson('schemas/site.schema.json'),
readJson('fixtures/intake.v1.valid.json'), readJson('fixtures/intake.v1.invalid.json'), readJson('sites.json')
]);
const intakeCheck = ajv.compile(intakeSchema);
const sitesCheck = ajv.compile(sitesSchema);
const siteCheck = ajv.compile(siteSchema);
if (!intakeCheck(validIntake)) throw new Error(`Valid intake rejected: ${ajv.errorsText(intakeCheck.errors)}`);
if (intakeCheck(invalidIntake)) throw new Error('Invalid intake fixture was accepted');
if (!sitesCheck(sites)) throw new Error(`Sites manifest rejected: ${ajv.errorsText(sitesCheck.errors)}`);
for (const key of Object.keys(sites.sites)) {
const data = await readJson(`sites/${key}/site.json`);
if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`);
}
async function filesUnder(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...await filesUnder(path));
else if (entry.isFile()) files.push(path);
else throw new Error(`Unsupported filesystem entry in public images: ${path}`);
}
return files;
}
const uploadRoot = join('public', 'images', 'uploads');
const ascii = (value) => Buffer.from(value, 'ascii');
const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature);
const isRasterForExtension = (buffer, extension) => {
if (extension === '.jpg' || extension === '.jpeg') return startsWith(buffer, Buffer.from([0xff, 0xd8, 0xff]));
if (extension === '.png') return startsWith(buffer, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (extension === '.webp') return buffer.length >= 12 && buffer.subarray(0, 4).equals(ascii('RIFF')) && buffer.subarray(8, 12).equals(ascii('WEBP'));
if (extension === '.avif' && buffer.length >= 16 && buffer.subarray(4, 8).equals(ascii('ftyp'))) {
for (let offset = 8; offset + 4 <= Math.min(buffer.length, 64); offset += 4) {
const brand = buffer.subarray(offset, offset + 4).toString('ascii');
if (brand === 'avif' || brand === 'avis') return true;
}
}
return false;
};
for (const path of await filesUnder('public/images')) {
const imagePath = relative('public/images', path).split(sep).join('/');
const info = await stat(path);
const uploadPath = relative(uploadRoot, path);
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
if (!isUpload) continue;
if (info.size > maxSourceImageBytes) throw new Error(`CMS image exceeds the 25 MiB source safety limit: ${imagePath}`);
if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`);
const extension = extname(uploadPath).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`);
const buffer = await readFile(path);
if (!isRasterForExtension(buffer, extension)) throw new Error(`CMS media does not match its raster extension: ${imagePath}`);
}
console.log('Intake fixtures, deployment manifest, site data, and bounded raster images are valid.');