Each policy now has exactly one copy: docs/PRIVACY.md in the app's own repository. The pages keep their URLs and chrome and render that file through a content collection, so the published page and the app's own documentation cannot drift. scripts/sync-external.mjs shallow-clones both repos into external/ from prebuild and predev — not from CI: Coolify builds the site from the repo, so a checkout that only ran in a Gitea job would never reach the deploy. It falls back to the raw file if git is unavailable, and takes <APP>_REF or <APP>_LOCAL for work against a branch or an unpushed working copy. A missing, empty or malformed policy fails the build, verified against the real image: the deploy stops rather than publishing an empty privacy page.
147 lines
5.9 KiB
JavaScript
147 lines
5.9 KiB
JavaScript
// Fetch the app repositories whose Markdown this site renders.
|
|
//
|
|
// Each app's docs/PRIVACY.md is the single copy of that policy: the app repo
|
|
// owns it, and this site renders it through a content collection (see
|
|
// src/content.config.ts). Nothing here is authored in this repository, and
|
|
// nothing is written back — this only makes the sources present at build time.
|
|
//
|
|
// It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site
|
|
// straight from the repo, so a checkout that only happened in a Gitea job
|
|
// would never reach the deployed page.
|
|
//
|
|
// Refs default to `main`. Point a source at a branch with e.g.
|
|
// AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with
|
|
// AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely).
|
|
//
|
|
// The guardrail is the point of the script: if a policy source is missing,
|
|
// empty, or malformed, the build FAILS. A privacy page that silently renders
|
|
// nothing is the one outcome this design exists to prevent.
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const EXTERNAL = join(ROOT, 'external');
|
|
|
|
const SOURCES = [
|
|
{
|
|
name: 'calendula',
|
|
repo: 'https://codeberg.org/jlmakiola/calendula.git',
|
|
raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`,
|
|
file: 'docs/PRIVACY.md',
|
|
},
|
|
{
|
|
name: 'agendula',
|
|
repo: 'https://codeberg.org/jlmakiola/agendula.git',
|
|
raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`,
|
|
file: 'docs/PRIVACY.md',
|
|
},
|
|
];
|
|
|
|
const REQUIRED_FRONTMATTER = ['title', 'description', 'updated'];
|
|
const MIN_BODY_CHARS = 400;
|
|
|
|
const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || '';
|
|
const log = (msg) => console.log(`[external] ${msg}`);
|
|
|
|
const git = (args, cwd) =>
|
|
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
|
|
|
/** Shallow single-branch clone, or a shallow fetch if it is already there. */
|
|
function syncRepo(source, ref, dir) {
|
|
if (existsSync(join(dir, '.git'))) {
|
|
git(['fetch', '--depth', '1', 'origin', ref], dir);
|
|
git(['checkout', '--detach', 'FETCH_HEAD'], dir);
|
|
return `fetched ${ref}`;
|
|
}
|
|
rmSync(dir, { recursive: true, force: true });
|
|
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]);
|
|
return `cloned ${ref}`;
|
|
}
|
|
|
|
/** Last resort when git is unavailable or the clone fails: the raw file. */
|
|
async function fetchRaw(source, ref, dir) {
|
|
const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } });
|
|
if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`);
|
|
const body = await res.text();
|
|
const target = join(dir, source.file);
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
writeFileSync(target, body);
|
|
return `fetched ${source.file} at ${ref} over HTTPS`;
|
|
}
|
|
|
|
/** Parse just enough frontmatter to know the entry will satisfy the schema. */
|
|
function validate(source, dir) {
|
|
const path = join(dir, source.file);
|
|
if (!existsSync(path)) return `${source.file} is missing`;
|
|
const text = readFileSync(path, 'utf8');
|
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
if (!match) return `${source.file} has no frontmatter block`;
|
|
const [, frontmatter, body] = match;
|
|
const missing = REQUIRED_FRONTMATTER.filter(
|
|
(key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter)
|
|
);
|
|
if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`;
|
|
// Strip HTML comments — the maintainer note must not count as content.
|
|
const prose = body.replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`;
|
|
if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`;
|
|
return null;
|
|
}
|
|
|
|
let failed = false;
|
|
mkdirSync(EXTERNAL, { recursive: true });
|
|
|
|
for (const source of SOURCES) {
|
|
const dir = join(EXTERNAL, source.name);
|
|
const local = env(source.name, 'LOCAL');
|
|
const ref = env(source.name, 'REF') || 'main';
|
|
|
|
if (local) {
|
|
// Development escape hatch: render a working copy on this machine, for a
|
|
// policy edit that is not pushed yet. Never set in CI or on the server.
|
|
const target = isAbsolute(local) ? local : resolve(ROOT, local);
|
|
if (!existsSync(target)) {
|
|
console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`);
|
|
failed = true;
|
|
continue;
|
|
}
|
|
if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
symlinkSync(target, dir, 'dir');
|
|
log(`${source.name}: local override -> ${target}`);
|
|
} else {
|
|
try {
|
|
log(`${source.name}: ${syncRepo(source, ref, dir)}`);
|
|
} catch (error) {
|
|
const reason = (error.stderr?.toString() || error.message).split('\n')[0];
|
|
log(`${source.name}: git failed (${reason}); falling back to the raw file`);
|
|
try {
|
|
log(`${source.name}: ${await fetchRaw(source, ref, dir)}`);
|
|
} catch (fallbackError) {
|
|
log(`${source.name}: fallback failed (${fallbackError.message})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const problem = validate(source, dir);
|
|
if (problem) {
|
|
console.error(`[external] ${source.name}: ${problem}`);
|
|
failed = true;
|
|
} else {
|
|
log(`${source.name}: ${source.file} ok`);
|
|
}
|
|
}
|
|
|
|
if (failed) {
|
|
console.error(
|
|
'\n[external] A privacy policy source is unusable, so the build stops here.\n' +
|
|
' These pages must never render empty — fix the source, or set\n' +
|
|
' <APP>_REF / <APP>_LOCAL if you are working against a branch.'
|
|
);
|
|
process.exit(1);
|
|
}
|