Render the app privacy policies from the app repos
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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user