feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure

This commit is contained in:
2026-05-07 13:04:02 +02:00
parent 51acfe9488
commit ee48a93824
133 changed files with 26049 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
type ConfigIssue = {
key: string;
message: string;
};
const WEAK_DEFAULT_VALUES = new Set([
"",
"calbook",
"calbook123",
"admin",
"admin123",
"passwort",
"password",
"123456",
"bitte-einen-langen-random-wert-setzen",
"bitte-einen-random-cron-secret-setzen",
"bitte-einen-random-salt-setzen",
"0123456789abcdef0123456789abcdef",
"bittesicher123!",
"change_me",
"changeme",
"replace_me",
"todo"
]);
function normalize(value: string | undefined | null) {
return (value ?? "").trim().toLowerCase();
}
function isWeakSecret(value: string | undefined | null) {
const normalized = normalize(value);
if (!normalized) return true;
if (WEAK_DEFAULT_VALUES.has(normalized)) return true;
if (normalized.startsWith("bitte-")) return true;
if (normalized.includes("random-wert-setzen")) return true;
if (normalized.includes("random-secret-setzen")) return true;
return false;
}
function validateRuntimeConfig(): ConfigIssue[] {
const issues: ConfigIssue[] = [];
const nextAuthSecret = process.env.NEXTAUTH_SECRET;
if (!nextAuthSecret || nextAuthSecret.trim().length < 32 || isWeakSecret(nextAuthSecret)) {
issues.push({
key: "NEXTAUTH_SECRET",
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
});
}
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || cronSecret.trim().length < 24 || isWeakSecret(cronSecret)) {
issues.push({
key: "CRON_SECRET",
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
});
}
const caldavKey = process.env.CALDAV_ENCRYPTION_KEY;
if (!caldavKey || caldavKey.trim().length < 32 || isWeakSecret(caldavKey)) {
issues.push({
key: "CALDAV_ENCRYPTION_KEY",
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
});
}
const jitsiSalt = process.env.JITSI_ROOM_SALT;
if (!jitsiSalt || jitsiSalt.trim().length < 24 || isWeakSecret(jitsiSalt)) {
issues.push({
key: "JITSI_ROOM_SALT",
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
});
}
const publicUrl = process.env.PUBLIC_URL;
if (publicUrl && publicUrl.trim()) {
try {
const parsed = new URL(publicUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
issues.push({
key: "PUBLIC_URL",
message: "muss mit http:// oder https:// beginnen."
});
}
} catch {
issues.push({
key: "PUBLIC_URL",
message: "ist kein gültiger URL-Wert."
});
}
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl || !databaseUrl.trim()) {
issues.push({
key: "DATABASE_URL",
message: "muss gesetzt sein."
});
} else {
try {
const parsed = new URL(databaseUrl);
const dbPassword = decodeURIComponent(parsed.password ?? "");
if (isWeakSecret(dbPassword) || dbPassword.length < 12) {
issues.push({
key: "DATABASE_URL",
message: "nutzt ein schwaches Datenbank-Passwort."
});
}
} catch {
issues.push({
key: "DATABASE_URL",
message: "ist kein gültiger URL-Wert."
});
}
}
return issues;
}
function validateSeedConfig(): ConfigIssue[] {
const issues: ConfigIssue[] = [];
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || adminPassword.trim().length < 12 || isWeakSecret(adminPassword)) {
issues.push({
key: "ADMIN_PASSWORD",
message: "muss gesetzt, stark und mindestens 12 Zeichen lang sein."
});
}
return issues;
}
function formatIssues(label: string, issues: ConfigIssue[]) {
return [
`[security] ${label}`,
...issues.map((issue) => `- ${issue.key}: ${issue.message}`)
].join("\n");
}
function isProductionRuntime() {
if (process.env.NODE_ENV !== "production") return false;
if (process.env.NEXT_PHASE === "phase-production-build") return false;
return true;
}
export function assertSecureRuntimeConfig() {
if (process.env.NODE_ENV === "test") return;
const issues = validateRuntimeConfig();
if (issues.length === 0) return;
const message = formatIssues("Unsichere Runtime-Konfiguration erkannt.", issues);
if (isProductionRuntime()) {
throw new Error(message);
}
// eslint-disable-next-line no-console
console.warn(message);
}
export function assertSecureSeedConfig() {
if (process.env.NODE_ENV === "test") return;
const issues = validateSeedConfig();
if (issues.length === 0) return;
throw new Error(formatIssues("Unsichere Seed-Konfiguration erkannt.", issues));
}

118
lib/security/request.ts Normal file
View File

@@ -0,0 +1,118 @@
import { fail } from "@/lib/api";
import { getPublicBaseUrl } from "@/lib/public-url";
const DEFAULT_MAX_JSON_BYTES = 64 * 1024;
function parseContentLength(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.floor(parsed);
}
function mutationMethod(method: string) {
const normalized = method.toUpperCase();
return normalized === "POST" || normalized === "PUT" || normalized === "PATCH" || normalized === "DELETE";
}
function isAllowedOrigin(origin: string, req: Request) {
const allowedOrigins = new Set<string>();
try {
allowedOrigins.add(new URL(req.url).origin);
} catch {
// Ignore invalid request URL.
}
try {
allowedOrigins.add(new URL(getPublicBaseUrl()).origin);
} catch {
// Ignore invalid public URL config.
}
if (process.env.NEXTAUTH_URL) {
try {
allowedOrigins.add(new URL(process.env.NEXTAUTH_URL).origin);
} catch {
// Ignore invalid NEXTAUTH_URL config.
}
}
return allowedOrigins.has(origin);
}
export function validateMutationRequestOrigin(req: Request) {
if (!mutationMethod(req.method)) return null;
const fetchSite = (req.headers.get("sec-fetch-site") ?? "").toLowerCase();
if (fetchSite === "cross-site") {
return fail("Ungültige Anfrage-Herkunft", 403);
}
const originHeader = req.headers.get("origin");
if (!originHeader) return null;
let origin: string;
try {
origin = new URL(originHeader).origin;
} catch {
return fail("Ungültige Anfrage-Herkunft", 403);
}
if (!isAllowedOrigin(origin, req)) {
return fail("Ungültige Anfrage-Herkunft", 403);
}
return null;
}
export async function readJsonBody(
req: Request,
options?: { maxBytes?: number }
) {
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
const contentLength = parseContentLength(req.headers.get("content-length"));
if (contentLength !== null && contentLength > maxBytes) {
return {
ok: false as const,
response: fail("Anfrage ist zu groß", 413)
};
}
let raw = "";
try {
raw = await req.text();
} catch {
return {
ok: false as const,
response: fail("Anfrage konnte nicht gelesen werden", 400)
};
}
if (Buffer.byteLength(raw, "utf8") > maxBytes) {
return {
ok: false as const,
response: fail("Anfrage ist zu groß", 413)
};
}
if (!raw.trim()) {
return {
ok: true as const,
data: {}
};
}
try {
return {
ok: true as const,
data: JSON.parse(raw) as unknown
};
} catch {
return {
ok: false as const,
response: fail("Ungültiges JSON", 400)
};
}
}