From 373b9278d14f998c02a5a8409a57562e074075fb Mon Sep 17 00:00:00 2001 From: Paul Schoeneck Date: Tue, 3 Nov 2026 08:50:48 +0100 Subject: [PATCH] feat: add Docker support with demo mode and database reset functionality --- .dockerignore | 14 +++++++ .gitattributes | 5 +++ Dockerfile | 24 +++++++++++ README.md | 77 +++++++++++++++++++++++++++++++++++ compose.demo.yaml | 36 ++++++++++++++++ scripts/docker-entrypoint.sh | 8 ++++ scripts/prepare-container.ts | 45 ++++++++++++++++++++ scripts/reset-instance.ts | 3 +- src/app/login/page.tsx | 3 +- src/components/login-form.tsx | 8 ++-- src/lib/demo.ts | 14 +++++++ tests/unit.test.ts | 16 ++++++++ 12 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 Dockerfile create mode 100644 compose.demo.yaml create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/prepare-container.ts create mode 100644 src/lib/demo.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a14ae3e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 Paul Schoeneck +# +# SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +.git +.next +node_modules +.env +.env.* +!.env.example +!.env.example.license +*.tsbuildinfo +test-results +playwright-report diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6ee25d9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 Paul Schoeneck +# +# SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd68788 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Paul Schoeneck +# +# SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +FROM node:22-bookworm-slim AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN sed -i 's/\r$//' scripts/docker-entrypoint.sh && npm run build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 +LABEL org.opencontainers.image.title="KIBU" \ + org.opencontainers.image.source="https://git.voidcube.de/pixo2000/KIBU" \ + org.opencontainers.image.licenses="AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms" +COPY --from=build --chown=node:node /app /app +USER node +EXPOSE 3000 +HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=6 \ + CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/login').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +ENTRYPOINT ["sh", "scripts/docker-entrypoint.sh"] diff --git a/README.md b/README.md index d204ed1..84e595d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,83 @@ SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms Lokale Web-App für die Kinderbetreuung mit Next.js, TypeScript und einer echten PostgreSQL-Datenbank. Keine externen Dienste, Fonts oder Bild-Uploads. Kinderkarten verwenden Initialen. +## Docker und oeffentliche Demo + +Das gleiche Image unterstuetzt normale Instanzen und einen ausdruecklichen +Demomodus. Die Demo ist eine voll bearbeitbare, gemeinsam genutzte Instanz mit +12 fiktiven Kindern, Wochenplaenen, Abholberechtigungen und Anwesenheitsverlauf. +Nur im Demomodus sind E-Mail und Passwort im normalen Login vorausgefuellt: +`demo@kibu.local` / `KIBU-Demo-2026!`. Es gibt keinen Authentifizierungs-Bypass. +Aufnahmerunden koennen wie in einer normalen Instanz angelegt werden. + +**Keine echten Daten eingeben! Jeder App-Start loescht alle Demo-Daten und +Zugaenge, ersetzt sie durch Beispieldaten und macht alte Sitzungen ungueltig.** +Auch ein automatischer App-Neustart nach einem Fehler setzt die Demo zurueck. +Die Demo-Datenbank liegt zusaetzlich auf einem fluechtigen `tmpfs`. +Nicht mehrfach skalieren: es ist eine einzelne App mit gemeinsamer Datenbank. +Der Login, Passwortaenderungen und andere Funktionen verhalten sich normal; +aendert jemand das gemeinsame Passwort, stellt ein App-Neustart es wieder her. + +### Lokal bauen und starten + +Docker Desktop mit Linux-Containern muss laufen. Compose verwendet ein separates +Projekt `kibu-demo`, eine separate Datenbank ohne veroeffentlichten DB-Port und +standardmaessig . Die bisherige lokale Instanz auf Port 3000 +und ihr Datenvolume bleiben unberuehrt. + +```powershell +docker compose -f compose.demo.yaml up -d --build --wait +docker compose -f compose.demo.yaml logs app +# App neu starten und damit Demo-Daten neu erzeugen: +docker compose -f compose.demo.yaml restart app +# Nur diese Demo stoppen: +docker compose -f compose.demo.yaml down +``` + +### Image pushen und auf einem anderen Host starten + +```powershell +docker build -t git.voidcube.de/pixo2000/kibu:demo . +docker login git.voidcube.de +docker push git.voidcube.de/pixo2000/kibu:demo +``` + +Auf dem Zielhost nur `compose.demo.yaml` bereitstellen. Bei privater Registry +vorher dort ebenfalls anmelden, dann: + +```powershell +docker compose -f compose.demo.yaml pull +docker compose -f compose.demo.yaml up -d --no-build --wait +``` + +`KIBU_IMAGE` kann ein anderes Image/Tag oder einen unveraenderlichen Digest setzen. +`DEMO_PORT` aendert den Host-Port. `DEMO_BIND_ADDRESS` ist absichtlich +`127.0.0.1`; fuer direkten externen Zugriff muss die Bindung bewusst geaendert +werden. Vorzugsweise HTTPS ueber einen Reverse Proxy nutzen und dann +`DEMO_SESSION_SECURE=true` setzen. Gemeinsame Demo-Zugaenge sind keine +Sicherheitsgrenze fuer vertrauliche Daten. Vor oeffentlichem Betrieb sind +Ressourcen-/Request-Limits am Proxy und die Betriebsgrenzen unten zu beachten. + +### Normale Instanz mit dem Image + +Ohne `DEMO_MODE=true` bleiben die Loginfelder leer; der Container fuehrt nur +`db:setup` aus und behaelt bestehende Daten. `DATABASE_URL` auf die eigene +PostgreSQL-Datenbank setzen; optional `BOOTSTRAP_EMAIL`, `BOOTSTRAP_PASSWORD`, +`APP_TIMEZONE` und `SESSION_SECURE` konfigurieren. Die App lauscht im Container +auf `0.0.0.0:3000`. DB-Readiness vor App-Start sicherstellen. + +Der destruktive Container-Demostart erfordert gemeinsam `DEMO_MODE=true`, +`DEMO_RESET_ON_START=true` und genau `demo-db/kibu_demo` als Datenbankziel. +Diese Einstellungen niemals auf echte Daten umbiegen. Die normalen lokalen +Reset-Befehle bleiben auf lokale Ziele begrenzt; nur das ausdruecklich +aktivierte, benannte Container-Demoziel ist zusaetzlich erlaubt. + +Das Image laeuft als unprivilegierter Node-Benutzer und enthaelt Lizenztexte, +Quellcode sowie die fuer Einrichtung und Build erforderlichen Werkzeuge. +`.env`, lokale Daten, Git-Metadaten und lokale Build-Artefakte werden nicht +in den Build-Kontext uebernommen. Vor dem Push muessen Lizenz-/Quellcodehinweise +und die veroeffentlichte Quellversion zum Image passen (siehe unten). + ## Lizenz Copyright (C) 2026 Paul Schoeneck. Originaler KIBU-Code steht unter diff --git a/compose.demo.yaml b/compose.demo.yaml new file mode 100644 index 0000000..5401c7f --- /dev/null +++ b/compose.demo.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Paul Schoeneck +# +# SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +name: kibu-demo +services: + app: + image: ${KIBU_IMAGE:-git.voidcube.de/pixo2000/kibu:demo} + build: + context: . + environment: + DATABASE_URL: postgresql://kibu_demo:kibu-demo-db-only@demo-db:5432/kibu_demo + DEMO_MODE: "true" + DEMO_RESET_ON_START: "true" + APP_TIMEZONE: Europe/Berlin + SESSION_SECURE: ${DEMO_SESSION_SECURE:-false} + ports: + - "${DEMO_BIND_ADDRESS:-127.0.0.1}:${DEMO_PORT:-3001}:3000" + depends_on: + demo-db: + condition: service_healthy + restart: unless-stopped + demo-db: + image: postgres:17-alpine + environment: + POSTGRES_DB: kibu_demo + POSTGRES_USER: kibu_demo + POSTGRES_PASSWORD: kibu-demo-db-only + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kibu_demo -d kibu_demo"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..f29a4d0 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Paul Schoeneck +# +# SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +set -eu +node node_modules/tsx/dist/cli.mjs scripts/prepare-container.ts +exec node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "${PORT:-3000}" diff --git a/scripts/prepare-container.ts b/scripts/prepare-container.ts new file mode 100644 index 0000000..b663d49 --- /dev/null +++ b/scripts/prepare-container.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Paul Schoeneck +// +// SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { demoLogin, isDemoDatabase } from "../src/lib/demo"; + +function prepare() { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL fehlt."); + if (process.env.DEMO_MODE && !["true", "false"].includes(process.env.DEMO_MODE)) { + throw new Error("DEMO_MODE muss true oder false sein."); + } + const demo = demoLogin(); + if (demo) { + const target = new URL(process.env.DATABASE_URL); + if (!isDemoDatabase(target) || !["postgres:", "postgresql:"].includes(target.protocol)) { + throw new Error("Demo-Start erfordert DEMO_RESET_ON_START=true und die separate Datenbank demo-db/kibu_demo. Nichts wurde veraendert."); + } + process.env.BOOTSTRAP_EMAIL = demo.email; + process.env.BOOTSTRAP_PASSWORD = demo.password; + console.log("DEMOMODUS: Gemeinsamer oeffentlicher Zugang; alle Demodaten werden bei jedem App-Start ersetzt."); + } else if (process.env.DEMO_RESET_ON_START === "true") { + throw new Error("DEMO_RESET_ON_START erfordert DEMO_MODE=true."); + } + const tsx = createRequire(import.meta.url).resolve("tsx/cli"); + const commands = demo + ? [["setup.ts"], ["reset-instance.ts", "demo", "--confirm-reset"]] + : [["setup.ts"]]; + for (const [script, ...args] of commands) { + const result = spawnSync(process.execPath, [tsx, fileURLToPath(new URL(script, import.meta.url)), ...args], { + env: process.env, stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${script} fehlgeschlagen; App wird nicht gestartet.`); + } +} + +try { + prepare(); +} catch (error) { + console.error("Container-Vorbereitung fehlgeschlagen:", error); + process.exitCode = 1; +} diff --git a/scripts/reset-instance.ts b/scripts/reset-instance.ts index a3e1e43..8ddcc14 100644 --- a/scripts/reset-instance.ts +++ b/scripts/reset-instance.ts @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { db } from "../src/lib/db"; +import { isDemoDatabase } from "../src/lib/demo"; async function reset() { const [mode, confirmation, ...extra] = process.argv.slice(2); @@ -16,7 +17,7 @@ async function reset() { if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL fehlt. Zuerst npm run configure ausfuehren."); const target = new URL(process.env.DATABASE_URL); if (!["postgres:", "postgresql:"].includes(target.protocol) - || !["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) + || (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) && !isDemoDatabase(target)) || !target.pathname.slice(1) || ["/postgres", "/template0", "/template1"].includes(target.pathname)) { throw new Error("Reset nur fuer eine ausdruecklich konfigurierte lokale Anwendungsdatenbank erlaubt."); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index d27e315..688fc53 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { currentStaff } from "@/lib/auth"; import { LoginForm } from "@/components/login-form"; +import { demoLogin } from "@/lib/demo"; import { Sprout, ShieldCheck, Sun } from "lucide-react"; export const dynamic = "force-dynamic"; @@ -25,7 +26,7 @@ export default async function LoginPage() {
SCHÖN, DASS DU DA BIST

Willkommen zurück.

Melde dich für euren Betreuungsbereich an.

- +

Geschützter Zugang für Betreuungspersonen

diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx index 24e3fc8..628a879 100644 --- a/src/components/login-form.tsx +++ b/src/components/login-form.tsx @@ -8,12 +8,14 @@ import { useActionState, useState } from "react"; import { ArrowRight } from "lucide-react"; import { login } from "@/app/actions"; -export function LoginForm() { +export function LoginForm({ demo }: { demo?: { email: string; password: string } }) { const [state, action, pending] = useActionState(login, {}); - const [email, setEmail] = useState(""); + const [email, setEmail] = useState(demo?.email ?? ""); return
+ {demo &&

Öffentliche Demo mit gemeinsamem Zugang. Bitte keine echten personenbezogenen Daten eingeben. + Änderungen sind für andere sichtbar und werden beim App-Neustart gelöscht.

} - + {state.error &&

{state.error}

}
; diff --git a/src/lib/demo.ts b/src/lib/demo.ts new file mode 100644 index 0000000..e1d5e10 --- /dev/null +++ b/src/lib/demo.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 Paul Schoeneck +// +// SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms + +export function demoLogin(environment: NodeJS.ProcessEnv = process.env) { + if (environment.DEMO_MODE !== "true") return undefined; + return { email: "demo@kibu.local", password: "KIBU-Demo-2026!" }; +} + +export function isDemoDatabase(target: URL, environment: NodeJS.ProcessEnv = process.env) { + return environment.DEMO_MODE === "true" + && environment.DEMO_RESET_ON_START === "true" + && target.hostname === "demo-db" && target.pathname === "/kibu_demo"; +} diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 8e7815f..7c34e5a 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -8,6 +8,22 @@ import { localClock, localInstant, pickupState } from "../src/lib/time"; import { childSchema, correctionSchema, daySchema, overrideSchema, aloneRulesSchema, contactSchema, slotSchema, applicationSchema } from "../src/lib/validation"; import { hashPassword, verifyPassword } from "../src/lib/password"; import { chooseWinners, invitationHash, newInvitation } from "../src/lib/admissions"; +import { demoLogin, isDemoDatabase } from "../src/lib/demo"; + +test("Demozugang nur bei explizitem Modus; Container-Reset nur fuer isolierte Demo-Datenbank", () => { + const normal = { NODE_ENV: "test" } as const; + assert.equal(demoLogin(normal), undefined); + assert.equal(demoLogin({ ...normal, DEMO_MODE: "false" }), undefined); + assert.equal(demoLogin({ ...normal, DEMO_MODE: "TRUE" }), undefined); + const demo = { ...normal, DEMO_MODE: "true", DEMO_RESET_ON_START: "true" }; + assert.deepEqual(demoLogin(demo), { email: "demo@kibu.local", password: "KIBU-Demo-2026!" }); + const target = new URL("postgresql://demo-db/kibu_demo"); + assert.equal(isDemoDatabase(target, demo), true); + assert.equal(isDemoDatabase(target, normal), false); + assert.equal(isDemoDatabase(target, { ...demo, DEMO_RESET_ON_START: "false" }), false); + assert.equal(isDemoDatabase(new URL("postgresql://demo-db/kibu"), demo), false); + assert.equal(isDemoDatabase(new URL("postgresql://production/kibu_demo"), demo), false); +}); test("Abholfenster: genau jetzt und bis einschließlich 15 Minuten, überfällig erst davor", () => { assert.equal(pickupState(null, "14:00"), "none");