feat: add Docker support with demo mode and database reset functionality

This commit is contained in:
Paul Schoeneck committed 2026-11-03 08:50:48 +01:00
1 parent 75af38a4b6
commit 373b9278d1
12 files changed
+248 -5

No files matched your search

+14
View File
@@ -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
+5
View File
@@ -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
+24
View File
@@ -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"]
+77
View File
@@ -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. 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 <http://127.0.0.1:3001>. 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 ## Lizenz
Copyright (C) 2026 Paul Schoeneck. Originaler KIBU-Code steht unter Copyright (C) 2026 Paul Schoeneck. Originaler KIBU-Code steht unter
+36
View File
@@ -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
+8
View File
@@ -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}"
+45
View File
@@ -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;
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { db } from "../src/lib/db"; import { db } from "../src/lib/db";
import { isDemoDatabase } from "../src/lib/demo";
async function reset() { async function reset() {
const [mode, confirmation, ...extra] = process.argv.slice(2); 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."); if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL fehlt. Zuerst npm run configure ausfuehren.");
const target = new URL(process.env.DATABASE_URL); const target = new URL(process.env.DATABASE_URL);
if (!["postgres:", "postgresql:"].includes(target.protocol) 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)) { || !target.pathname.slice(1) || ["/postgres", "/template0", "/template1"].includes(target.pathname)) {
throw new Error("Reset nur fuer eine ausdruecklich konfigurierte lokale Anwendungsdatenbank erlaubt."); throw new Error("Reset nur fuer eine ausdruecklich konfigurierte lokale Anwendungsdatenbank erlaubt.");
} }
+2 -1
View File
@@ -5,6 +5,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { currentStaff } from "@/lib/auth"; import { currentStaff } from "@/lib/auth";
import { LoginForm } from "@/components/login-form"; import { LoginForm } from "@/components/login-form";
import { demoLogin } from "@/lib/demo";
import { Sprout, ShieldCheck, Sun } from "lucide-react"; import { Sprout, ShieldCheck, Sun } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,7 +26,7 @@ export default async function LoginPage() {
<div className="login-form-wrap"><span className="section-icon"><ShieldCheck size={25} /></span> <div className="login-form-wrap"><span className="section-icon"><ShieldCheck size={25} /></span>
<span className="eyebrow">SCHÖN, DASS DU DA BIST</span> <span className="eyebrow">SCHÖN, DASS DU DA BIST</span>
<h2>Willkommen zurück.</h2><p className="muted">Melde dich für euren Betreuungsbereich an.</p> <h2>Willkommen zurück.</h2><p className="muted">Melde dich für euren Betreuungsbereich an.</p>
<LoginForm /> <LoginForm demo={demoLogin()} />
<p className="login-note"><ShieldCheck size={14} /> Geschützter Zugang für Betreuungspersonen</p> <p className="login-note"><ShieldCheck size={14} /> Geschützter Zugang für Betreuungspersonen</p>
</div> </div>
</section> </section>
+5 -3
View File
@@ -8,12 +8,14 @@ import { useActionState, useState } from "react";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { login } from "@/app/actions"; import { login } from "@/app/actions";
export function LoginForm() { export function LoginForm({ demo }: { demo?: { email: string; password: string } }) {
const [state, action, pending] = useActionState(login, {}); const [state, action, pending] = useActionState(login, {});
const [email, setEmail] = useState(""); const [email, setEmail] = useState(demo?.email ?? "");
return <form action={action} className="stack"> return <form action={action} className="stack">
{demo && <p className="form-notice">Öffentliche Demo mit gemeinsamem Zugang. Bitte keine echten personenbezogenen Daten eingeben.
Änderungen sind für andere sichtbar und werden beim App-Neustart gelöscht.</p>}
<label>E-Mail-Adresse<input type="email" name="email" autoComplete="username" required maxLength={254} placeholder="name@betreuung.de" value={email} onChange={(event) => setEmail(event.target.value)} /></label> <label>E-Mail-Adresse<input type="email" name="email" autoComplete="username" required maxLength={254} placeholder="name@betreuung.de" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>Passwort<input type="password" name="password" autoComplete="current-password" required maxLength={256} placeholder="Dein Passwort" /></label> <label>Passwort<input type="password" name="password" autoComplete="current-password" required maxLength={256} placeholder="Dein Passwort" defaultValue={demo?.password ?? ""} /></label>
{state.error && <p className="form-error" role="alert">{state.error}</p>} {state.error && <p className="form-error" role="alert">{state.error}</p>}
<button className="button primary full" disabled={pending}>{pending ? "Wird angemeldet …" : <>Anmelden <ArrowRight size={17} /></>}</button> <button className="button primary full" disabled={pending}>{pending ? "Wird angemeldet …" : <>Anmelden <ArrowRight size={17} /></>}</button>
</form>; </form>;
+14
View File
@@ -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";
}
+16
View File
@@ -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 { childSchema, correctionSchema, daySchema, overrideSchema, aloneRulesSchema, contactSchema, slotSchema, applicationSchema } from "../src/lib/validation";
import { hashPassword, verifyPassword } from "../src/lib/password"; import { hashPassword, verifyPassword } from "../src/lib/password";
import { chooseWinners, invitationHash, newInvitation } from "../src/lib/admissions"; 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", () => { test("Abholfenster: genau jetzt und bis einschließlich 15 Minuten, überfällig erst davor", () => {
assert.equal(pickupState(null, "14:00"), "none"); assert.equal(pickupState(null, "14:00"), "none");