Images for a post live next to it in src/content/blog/<slug>/ and are referenced as ./<slug>/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
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
// Spærre før build: intet blogindlæg går live uden at være godkendt.
|
// 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.
|
// 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/<slug>/),
|
||||||
// lavet med en nøgle som kun godkendelsestjenesten og denne workflow kender. Et
|
// 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
|
// 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.
|
// intet eller et forkert stempel, og så stopper buildet før noget bliver deployet.
|
||||||
@@ -12,7 +13,6 @@
|
|||||||
// PUBLISH_KEY=<hex> node scripts/verify-approvals.mjs
|
// PUBLISH_KEY=<hex> node scripts/verify-approvals.mjs
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const DIR = 'src/content/blog';
|
const DIR = 'src/content/blog';
|
||||||
const key = process.env.PUBLISH_KEY;
|
const key = process.env.PUBLISH_KEY;
|
||||||
@@ -21,8 +21,9 @@ if (!key) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skal matche godkendelsestjenesten byte for byte.
|
// Skal matche godkendelsestjenesten byte for byte. Et indlæg uden billeder har
|
||||||
const canonical = (relPath, text) => {
|
// 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');
|
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||||
if (lines[0] === '---') {
|
if (lines[0] === '---') {
|
||||||
const end = lines.indexOf('---', 1);
|
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);
|
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 <slug>.md ligger fladt i mappen <slug>/ 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) => {
|
const stampOf = (text) => {
|
||||||
@@ -43,20 +60,27 @@ const stampOf = (text) => {
|
|||||||
return { stamp: g ? g.replace(/^godkendt:\s*/, '').replace(/^['"]|['"]$/g, '') : null, draft };
|
return { stamp: g ? g.replace(/^godkendt:\s*/, '').replace(/^['"]|['"]$/g, '') : null, draft };
|
||||||
};
|
};
|
||||||
|
|
||||||
const files = fs.readdirSync(DIR, { recursive: true })
|
const entries = fs.readdirSync(DIR, { withFileTypes: true });
|
||||||
.filter((f) => f.endsWith('.md') || f.endsWith('.mdx'))
|
const files = entries.filter((e) => e.isFile() && /\.mdx?$/.test(e.name)).map((e) => `${DIR}/${e.name}`);
|
||||||
.map((f) => path.posix.join(DIR, f.split(path.sep).join('/')));
|
|
||||||
|
|
||||||
let bad = 0;
|
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) {
|
for (const rel of files) {
|
||||||
const text = fs.readFileSync(rel, 'utf8');
|
const text = fs.readFileSync(rel, 'utf8');
|
||||||
const { stamp, draft } = stampOf(text);
|
const { stamp, draft } = stampOf(text);
|
||||||
if (draft) { console.error(`✗ ${rel}: udkast (draft: true) må ikke ligge i det offentlige repo`); bad++; continue; }
|
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; }
|
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));
|
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; }
|
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) {
|
if (bad) {
|
||||||
console.error(`\n${bad} indlæg er ikke godkendt. Intet bliver udgivet.`);
|
console.error(`\n${bad} indlæg er ikke godkendt. Intet bliver udgivet.`);
|
||||||
|
|||||||
Reference in New Issue
Block a user