From e294be18c7e814a9a18c683d7dacd0be19ba3a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Fri, 9 Oct 2026 19:45:23 +0200 Subject: [PATCH] Cover a post's images in its approval stamp Images for a post live next to it in src/content/blog// and are referenced as .//file.png, so Astro optimises them and resolves the path in the preview build as well. The stamp now also covers the sha256 of every file in that folder: swapping or adding an image after approval fails the build, and so does a folder without an approved post. Posts without images keep their existing stamps. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu --- scripts/verify-approvals.mjs | 44 ++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/scripts/verify-approvals.mjs b/scripts/verify-approvals.mjs index a030a8f..8cbca88 100644 --- a/scripts/verify-approvals.mjs +++ b/scripts/verify-approvals.mjs @@ -1,7 +1,8 @@ // Spærre før build: intet blogindlæg går live uden at være godkendt. // // Hvert indlæg i src/content/blog skal bære et `godkendt:`-stempel i frontmatter. -// Stemplet er en HMAC-SHA256 over filens sti + indhold (uden selve stempel-linjen), +// Stemplet er en HMAC-SHA256 over filens sti + indhold (uden selve stempel-linjen) +// + sha256 af hvert billede i indlæggets mappe (src/content/blog//), // lavet med en nøgle som kun godkendelsestjenesten og denne workflow kender. Et // indlæg der er lagt direkte i repoet, eller ændret efter godkendelse, har derfor // intet eller et forkert stempel, og så stopper buildet før noget bliver deployet. @@ -12,7 +13,6 @@ // PUBLISH_KEY= node scripts/verify-approvals.mjs import crypto from 'node:crypto'; import fs from 'node:fs'; -import path from 'node:path'; const DIR = 'src/content/blog'; const key = process.env.PUBLISH_KEY; @@ -21,8 +21,9 @@ if (!key) { process.exit(1); } -// Skal matche godkendelsestjenesten byte for byte. -const canonical = (relPath, text) => { +// Skal matche godkendelsestjenesten byte for byte. Et indlæg uden billeder har +// ingen @asset-linjer, så dets stempel er det samme som før billeder fandtes. +const canonical = (relPath, text, assets = []) => { const lines = text.replace(/\r\n/g, '\n').split('\n'); if (lines[0] === '---') { const end = lines.indexOf('---', 1); @@ -30,7 +31,23 @@ const canonical = (relPath, text) => { for (let i = end - 1; i > 0; i--) if (/^godkendt:/.test(lines[i])) lines.splice(i, 1); } } - return `${relPath}\n${lines.join('\n')}`; + const tail = assets.map(([p, h]) => `\n@asset ${p} ${h}`).join(''); + return `${relPath}\n${lines.join('\n')}${tail}`; +}; + +const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); + +// Billeder til .md ligger fladt i mappen / ved siden af. +const assetsFor = (rel) => { + const dir = rel.replace(/\.mdx?$/, ''); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }) + .map((e) => { + if (!e.isFile()) throw new Error(`${dir}/${e.name}: undermapper understøttes ikke`); + const p = `${dir}/${e.name}`; + return [p, sha256(fs.readFileSync(p))]; + }) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); }; const stampOf = (text) => { @@ -43,20 +60,27 @@ const stampOf = (text) => { return { stamp: g ? g.replace(/^godkendt:\s*/, '').replace(/^['"]|['"]$/g, '') : null, draft }; }; -const files = fs.readdirSync(DIR, { recursive: true }) - .filter((f) => f.endsWith('.md') || f.endsWith('.mdx')) - .map((f) => path.posix.join(DIR, f.split(path.sep).join('/'))); +const entries = fs.readdirSync(DIR, { withFileTypes: true }); +const files = entries.filter((e) => e.isFile() && /\.mdx?$/.test(e.name)).map((e) => `${DIR}/${e.name}`); let bad = 0; +// En billedmappe uden et indlæg ved siden af er aldrig blevet godkendt. +for (const e of entries.filter((e) => e.isDirectory())) { + if (!files.some((f) => f.replace(/\.mdx?$/, '') === `${DIR}/${e.name}`)) { + console.error(`✗ ${DIR}/${e.name}/: filer uden et godkendt indlæg`); bad++; + } +} for (const rel of files) { const text = fs.readFileSync(rel, 'utf8'); const { stamp, draft } = stampOf(text); if (draft) { console.error(`✗ ${rel}: udkast (draft: true) må ikke ligge i det offentlige repo`); bad++; continue; } if (!stamp) { console.error(`✗ ${rel}: mangler godkendelse`); bad++; continue; } - const want = 'v1:' + crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(canonical(rel, text)).digest('hex'); + let assets; + try { assets = assetsFor(rel); } catch (err) { console.error(`✗ ${err.message}`); bad++; continue; } + const want = 'v1:' + crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(canonical(rel, text, assets)).digest('hex'); const ok = stamp.length === want.length && crypto.timingSafeEqual(Buffer.from(stamp), Buffer.from(want)); if (!ok) { console.error(`✗ ${rel}: godkendelsen passer ikke til indholdet (ændret efter godkendelse?)`); bad++; continue; } - console.log(`✓ ${rel}`); + console.log(`✓ ${rel}${assets.length ? ` (+${assets.length} billeder)` : ''}`); } if (bad) { console.error(`\n${bad} indlæg er ikke godkendt. Intet bliver udgivet.`);