70 lines
3.7 KiB
JavaScript
70 lines
3.7 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';
|
|
|
|
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 allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
|
|
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);
|
|
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${imagePath}`);
|
|
|
|
const uploadPath = relative(uploadRoot, path);
|
|
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
|
|
if (!isUpload) continue;
|
|
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.');
|