diff --git a/.gitignore b/.gitignore index e7eb29e..a554bff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ .next/ out/ +build/ next-env.d.ts *.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6655a..3000cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,29 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue ## [Unveröffentlicht] +## [1.0.1] – 2026-10-03 + +Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar. + +### Behoben + +- **Im Docker-Abbild ließ sich kein erstes Konto anlegen.** Die README nannte + `node werkzeuge/konto.js`, das Abbild enthielt aber nur die Sicherungsskripte, + und `tsx` gibt es dort nicht. Ein frisch aufgesetzter Server stand vor einer + Anmeldeseite ohne Konto. Das Kontowerkzeug wird jetzt beim Bau mit esbuild zu + einer Datei gebündelt (`npm run werkzeuge:bauen`) und liegt unter + `/app/werkzeuge/konto.js` +- **Windows-Pfade in Dateinamen von Anhängen** wurden unter Linux nicht + abgetrennt – aus `C:\Users\x\foto.jpg` wurde `C:Usersxfoto.jpg`. Nur die + Anzeige war betroffen, gespeichert wird unter der UUID. Der Test dazu war + unter Windows grün und unter Linux rot + +### Hinzugefügt + +- `npm run abnahme:container` – Abnahmelauf gegen das gebaute Docker-Abbild: + Lebenszeichen, Betrieb ohne root, erstes Konto, Anmeldung, Passwort neu + setzen, Sicherung im Volume + ## [1.0.0] – 2026-09-11 Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die diff --git a/Dockerfile b/Dockerfile index 65348b0..952706f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,9 @@ ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=abhaengigkeiten /app/node_modules ./node_modules COPY . . RUN npm run build +# Das Kontowerkzeug als eine einzige JS-Datei – im Abbild gibt es kein tsx. +# Die nativen Module bleiben draußen, sie bringt die Standalone-Ausgabe mit. +RUN npm run werkzeuge:bauen # --- Laufzeit ------------------------------------------------------------- FROM node:${NODE_VERSION} AS laufzeit @@ -51,8 +54,9 @@ COPY --from=bau /app/public ./public COPY --from=bau /app/content ./content COPY --from=bau /app/src/lib/db/migrationen ./migrationen -# Werkzeuge für Sicherung und Wiederherstellung im Container. +# Werkzeuge für Sicherung, Wiederherstellung und das erste Konto. COPY --from=bau /app/scripts/im-container ./werkzeuge +COPY --from=bau /app/build/werkzeuge/konto.js ./werkzeuge/konto.js RUN chmod +x ./werkzeuge/*.sh RUN mkdir -p /daten && chown -R node:node /daten /app diff --git a/README.md b/README.md index 32c56bc..650627d 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,13 @@ Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto anlegen: ```bash -docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar? -docker compose exec lernbar sh -c \ - 'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder' +docker compose exec lernbar node werkzeuge/konto.js \ + --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder ``` -> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom -> Entwicklungsrechner aus: -> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder` +Ohne `--passwort` wird eines erzeugt und ausgegeben. Gibt es die Adresse +schon, setzt derselbe Aufruf das Passwort neu – das ist zugleich der Weg, wenn +das einzige Ausbilderkonto sein Passwort vergessen hat. Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und das Ausbildungsverhältnis mit Beginndatum einrichten. @@ -244,6 +243,7 @@ npm run typecheck # TypeScript npm run lint # ESLint npm run test # Vitest – Logik, die wehtut (315 Tests) npm run abnahme # der Abnahmelauf, siehe unten +npm run abnahme:container # derselbe Weg gegen das Docker-Abbild ``` ### Der Abnahmelauf @@ -269,6 +269,16 @@ Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu prüfen. +### Der Containerlauf + +`npm run abnahme` prüft über `next start`, betrieben wird aber der Container. +`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem +Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über +`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sicherung im Container. +Danach werden Container, Volume und Abbild wieder entfernt. + +Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`). + ### Einzelne Zusagen ```bash diff --git a/package.json b/package.json index 79f3f5d..5eb9297 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,13 @@ "e2e": "playwright test", "inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts", "konto:anlegen": "tsx scripts/konto-anlegen.ts", + "werkzeuge:bauen": "esbuild scripts/konto-anlegen.ts --bundle --platform=node --target=node22 --format=cjs --external:better-sqlite3 --external:@node-rs/argon2 --outfile=build/werkzeuge/konto.js", "backup": "bash scripts/im-container/sicherung.sh", "restore": "bash scripts/im-container/wiederherstellen.sh", "screenshots": "tsx scripts/screenshots.ts", "symbole": "tsx scripts/symbole.ts", - "abnahme": "node scripts/pruefungen/abnahme.mjs" + "abnahme": "node scripts/pruefungen/abnahme.mjs", + "abnahme:container": "node scripts/pruefungen/container.mjs" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/scripts/pruefungen/container.mjs b/scripts/pruefungen/container.mjs new file mode 100644 index 0000000..7a90ea3 --- /dev/null +++ b/scripts/pruefungen/container.mjs @@ -0,0 +1,165 @@ +import { chromium } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; + +/** + * Der Abnahmelauf gegen das Docker-Abbild. + * + * `abnahme.mjs` prüft die Anwendung über `next start`. Betrieben wird sie aber + * als Container – und was dort fehlt, fällt dabei nicht auf. So war es mit dem + * Kontowerkzeug: Die README beschrieb `node werkzeuge/konto.js`, im Abbild lag + * es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da. + * + * Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg + * aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sicherung. + * + * node scripts/pruefungen/container.mjs + * + * Voraussetzung: Docker läuft, Port 3398 ist frei (LERNBAR_CONTAINER_PORT). + */ + +const PORT = Number(process.env.LERNBAR_CONTAINER_PORT ?? 3398); +const BASIS = `http://localhost:${PORT}`; +const ABBILD = 'lernbar:abnahme'; +const NAME = `lernbar-abnahme-${Date.now()}`; + +const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'container-ausbilder' }; + +let fehler = 0; + +function pruefe(bedingung, text, zusatz = '') { + console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); + if (!bedingung) fehler += 1; +} + +function abschnitt(titel) { + console.log(`\n${titel}`); +} + +function docker(argumente, zusatz = {}) { + return spawnSync('docker', argumente, { encoding: 'utf8', ...zusatz }); +} + +function imContainer(...argumente) { + const ergebnis = docker(['exec', NAME, ...argumente]); + return { ende: ergebnis.status, ausgabe: `${ergebnis.stdout}${ergebnis.stderr}` }; +} + +let browser = null; + +function aufraeumen() { + // -v nimmt das namenlose Volume mit – sonst bleibt bei jedem Lauf eins liegen. + docker(['rm', '-fv', NAME], { stdio: 'ignore' }); + docker(['rmi', ABBILD], { stdio: 'ignore' }); +} + +async function portBelegt() { + try { + await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(1000) }); + return true; + } catch { + return false; + } +} + +async function gesund(sekunden = 60) { + for (let i = 0; i < sekunden; i += 1) { + try { + const antwort = await fetch(`${BASIS}/api/gesundheit`); + if (antwort.ok) return true; + } catch { + /* noch nicht da */ + } + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} + +async function anmelden(email, passwort) { + const kontext = await browser.newContext({ locale: 'de-DE' }); + const seite = await kontext.newPage(); + seite.setDefaultTimeout(30000); + await seite.goto(`${BASIS}/anmelden`); + await seite.fill('#email', email); + await seite.fill('#passwort', passwort); + await seite.click('button[type=submit]'); + const angekommen = await seite + .waitForURL(`${BASIS}/`, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + await kontext.close(); + return angekommen; +} + +async function main() { + if (docker(['info'], { stdio: 'ignore' }).status !== 0) { + console.error('Docker ist nicht erreichbar.'); + process.exit(2); + } + if (await portBelegt()) { + console.error(`Auf Port ${PORT} antwortet schon etwas. Anderen Port über LERNBAR_CONTAINER_PORT setzen.`); + process.exit(2); + } + + // --- C1 Das Abbild entsteht ---------------------------------------------- + + abschnitt('C1 · Das Abbild entsteht und startet'); + + console.log(' … baue das Abbild, das dauert beim ersten Mal einige Minuten'); + const bau = docker(['build', '-t', ABBILD, '.'], { stdio: ['ignore', 'ignore', 'pipe'] }); + pruefe(bau.status === 0, 'Das Abbild lässt sich bauen', bau.status === 0 ? '' : bau.stderr.split('\n').slice(-5).join(' ')); + if (bau.status !== 0) return; + + const start = docker(['run', '-d', '--name', NAME, '-p', `127.0.0.1:${PORT}:3000`, ABBILD]); + pruefe(start.status === 0, 'Der Container startet'); + if (start.status !== 0) return; + + pruefe(await gesund(), 'Die Anwendung meldet sich gesund'); + pruefe(imContainer('id', '-un').ausgabe.trim() === 'node', 'Sie läuft nicht als root'); + + // --- C2 Das erste Konto --------------------------------------------------- + + abschnitt('C2 · Das erste Konto entsteht im Container'); + + const werkzeuge = imContainer('ls', '/app/werkzeuge').ausgabe; + pruefe(werkzeuge.includes('konto.js'), 'Das Kontowerkzeug liegt im Abbild'); + + const anlegen = imContainer( + 'node', 'werkzeuge/konto.js', + '--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw, + ); + pruefe(anlegen.ende === 0 && anlegen.ausgabe.includes('Konto angelegt'), 'Der Aufruf aus der README legt das Konto an', anlegen.ausgabe.split('\n')[0]); + + browser = await chromium.launch(); + pruefe(await anmelden(AUSBILDER.email, AUSBILDER.pw), 'Der Ausbilder meldet sich mit diesem Konto an'); + pruefe(!(await anmelden(AUSBILDER.email, 'falsch-falsch')), 'Ein falsches Passwort wird abgewiesen'); + + // Derselbe Aufruf ist der Rettungsweg, wenn das einzige Ausbilderkonto sein + // Passwort vergessen hat. + const neu = imContainer('node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email); + const neuesPw = neu.ausgabe.match(/Passwort: (\S+)/)?.[1]; + pruefe(neu.ende === 0 && neu.ausgabe.includes('gab es schon') && neuesPw, 'Ein zweiter Aufruf setzt das Passwort neu'); + pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung'); + pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr'); + + // --- C3 Sicherung im Container ------------------------------------------- + + abschnitt('C3 · Sicherung im Container'); + + const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen'); + pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1)); + const archive = imContainer('sh', '-c', 'ls /daten/sicherungen/lernbar-*.tar.gz').ausgabe; + pruefe(/lernbar-\d{8}-\d{6}\.tar\.gz/.test(archive), 'Das Archiv liegt im Volume'); +} + +try { + await main(); +} catch (e) { + console.error(e); + fehler += 1; +} finally { + if (browser) await browser.close(); + aufraeumen(); +} + +console.log(fehler === 0 ? '\n✓ Containerabnahme bestanden.' : `\n✗ ${fehler} Prüfung(en) nicht erfüllt.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/src/lib/anhaenge/dateien.ts b/src/lib/anhaenge/dateien.ts index cbbd324..88f43c1 100644 --- a/src/lib/anhaenge/dateien.ts +++ b/src/lib/anhaenge/dateien.ts @@ -67,7 +67,9 @@ export function megabyte(bytes: number): string { * etwas verloren. */ export function saubererName(name: string): string { - return path + // win32.basename trennt an / und \ – der Name kommt vom Browser, nicht vom + // Server, und ein Windows-Pfad darf auch unter Linux nicht durchrutschen. + return path.win32 .basename(name) .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/["\\]/g, '')