Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+27
View File
@@ -0,0 +1,27 @@
import { mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, chownSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
const passwordFile = process.env.APP_DATABASE_PASSWORD_FILE;
if (!passwordFile) throw new Error('Runtime password file is required.');
const directory = passwordFile.slice(0, passwordFile.lastIndexOf('/'));
mkdirSync(directory, { recursive: true, mode: 0o700 });
const password = existsSync(passwordFile) ? readFileSync(passwordFile, 'utf8').trim() : randomBytes(32).toString('hex');
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
if (!existsSync(passwordFile)) writeFileSync(passwordFile, password + '\n', { flag: 'wx', mode: 0o400 });
chmodSync(passwordFile, 0o400);
chownSync(passwordFile, 1001, 1001);
chownSync(directory, 1001, 1001);
const ownerUrl = new URL('postgresql://postgres:5432/' + encodeURIComponent(process.env.POSTGRES_DB || 'kita_planer'));
ownerUrl.username = process.env.POSTGRES_USER || 'kita_planer';
ownerUrl.password = process.env.POSTGRES_PASSWORD || '';
if (!ownerUrl.password) throw new Error('Database owner password is required.');
const environment = { ...process.env, DATABASE_URL: ownerUrl.toString(), APP_DATABASE_PASSWORD: password };
for (const args of [['node_modules/prisma/build/index.js', 'migrate', 'deploy'], ['scripts/provision-runtime-role.mjs']]) {
const result = spawnSync(process.execPath, args, { env: environment, stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
for (const path of ['/app/uploads', '/app/uploads/announcements', '/app/uploads/announcements/announcements', '/app/runtime-cache']) {
mkdirSync(path, { recursive: true, mode: 0o700 });
chownSync(path, 1001, 1001);
}
console.log('Database initialized; restricted application access and volumes are ready.');
+42
View File
@@ -0,0 +1,42 @@
// Runs inside the application container. No external cron service or browser is required.
const secret = process.env.NOTDIENST_JOB_SECRET;
if (!secret || secret.length < 32) {
console.error(
"Notdienst-Fristprüfung deaktiviert: NOTDIENST_JOB_SECRET muss mindestens 32 Zeichen haben.",
);
process.exit(1);
}
const port = Number(process.env.PORT || 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(1);
let stopped = false;
let timer;
async function tick() {
const started = Date.now();
try {
const response = await fetch(
`http://127.0.0.1:${port}/api/jobs/notdienstalarme`,
{
method: "POST",
headers: { authorization: `Bearer ${secret}` },
signal: AbortSignal.timeout(50000),
},
);
if (!response.ok)
console.error(
`Notdienst-Fristprüfung: HTTP ${response.status}; erneuter Versuch in einer Minute.`,
);
} catch {
console.error(
"Notdienst-Fristprüfung momentan nicht erreichbar; erneuter Versuch in einer Minute.",
);
}
if (!stopped)
timer = setTimeout(tick, Math.max(1000, 60000 - (Date.now() - started)));
}
for (const signal of ["SIGINT", "SIGTERM"])
process.on(signal, () => {
stopped = true;
clearTimeout(timer);
process.exit(0);
});
timer = setTimeout(tick, 5000);
+5 -1
View File
@@ -1,3 +1,7 @@
import { cpSync } from 'node:fs';
import { cpSync, mkdirSync } from 'node:fs';
cpSync('.next/static', '.next/standalone/.next/static', { recursive: true });
cpSync('public', '.next/standalone/public', { recursive: true });
mkdirSync('.next/standalone/scripts', { recursive: true });
cpSync('scripts/start-production.sh', '.next/standalone/scripts/start-production.sh');
cpSync('scripts/notdienst-alarm-worker.mjs', '.next/standalone/scripts/notdienst-alarm-worker.mjs');
cpSync('scripts/runtime-database-url.mjs', '.next/standalone/scripts/runtime-database-url.mjs');
+8
View File
@@ -0,0 +1,8 @@
import { readFileSync } from 'node:fs';
const file = process.env.APP_DATABASE_PASSWORD_FILE;
if (!file) throw new Error('Runtime password file is required.');
const password = readFileSync(file, 'utf8').trim();
if (!/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid runtime password file.');
const url = new URL('postgresql://kita_runtime@postgres:5432/' + encodeURIComponent(process.env.APP_DATABASE_NAME || 'kita_planer'));
url.password = password;
process.stdout.write(url.toString());
+9 -1
View File
@@ -1,4 +1,12 @@
#!/bin/sh
set -eu
# Apply reviewed migrations separately before starting the runtime.
cd "$(dirname "$0")/.."
if [ -n "${APP_DATABASE_PASSWORD_FILE:-}" ]; then
DATABASE_URL="$(node scripts/runtime-database-url.mjs)"
export DATABASE_URL
fi
# Schema changes are applied by the maintenance image before runtime startup.
if [ -n "${NOTDIENST_JOB_SECRET:-}" ]; then
node scripts/notdienst-alarm-worker.mjs &
fi
exec node server.js