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.');