Add plan.md and CLAUDE.md

Architecture decisions with rationale (plan.md) and repo-specific
build/run commands plus style rules (CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tleiningerandClaude Opus 4.8 committed 2026-09-20 21:51:18 +02:00
1 parent 8b172e2c3c
commit 2298051bf4
2 files changed
+496

No files matched your search

+430
View File
@@ -0,0 +1,430 @@
# elternbeirat-igmh.de — Neuaufbau
Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf
eigener Infrastruktur.
Stand: 2026-09-20 · Verantwortlich: Tom (Thomas Leininger)
---
## 1. Ausgangslage
- Die Domain `elternbeirat-igmh.de` lag bei Maik Palm (ausscheidendes
Elternbeirat-Mitglied) im Paket „STRATO Hosting Basic", Auftragsnummer 9157927.
- Domaininhaber-Wechsel und Domainumzug sind beidseitig unterschrieben
(19.09.2026), die Einreichung bei Strato steht noch aus.
- Ziel-Paket: Toms „STRATO Mail Plus" (Auftragsnummer 8844576) — Domain + E-Mail,
**kein Webspace**.
- **E-Mail bleibt bei Strato.** Nur die Website zieht auf eigene Hardware.
- **Die bisherige Website ist aktuell offline.** Der Inhaltsbestand ist damit der
zeitkritischste offene Punkt (→ Abschnitt 9).
---
## 2. Ziele und Nicht-Ziele
**Ziele**
- Öffentliche Informationsseite des Elternbeirats: wer, wann, welche Protokolle,
wie erreichbar.
- Betrieb auf eigener Infrastruktur (Unraid), ohne fremden Hoster.
- Inhalte versionierbar und ohne Datenbank — ein `git clone` ist das vollständige
Backup.
- Wartungsarm: keine Plugin-Updates, keine PHP-Sicherheitslücken, kein CMS-Login
als Angriffsfläche.
**Nicht-Ziele (bewusst)**
- Kein CMS mit Web-Editor in Stufe 1. Falls andere Beiratsmitglieder später selbst
redaktionell arbeiten sollen, ist das ein eigener Ausbauschritt (→ Abschnitt 11).
- Keine Benutzerkonten, kein Login, kein Mitgliederbereich.
- Keine Datenbank.
- Keine externen Einbindungen (Fonts, Analytics, Maps, Social Widgets) — aus
Datenschutzgründen, siehe Abschnitt 10.
---
## 3. Architekturentscheidungen
### AE-1: Blazor mit Static Server-Side Rendering, nicht WebAssembly
**Entscheidung:** Blazor Web App mit Interaktivitätsmodus *None* (reines statisches
SSR). Zielframework .NET 10 (LTS).
**Begründung:** Blazor WASM lädt mehrere MB Runtime vor dem ersten sichtbaren
Buchstaben, liefert Suchmaschinen und Link-Vorschauen (WhatsApp, Signal, Messenger
— der Hauptverbreitungsweg bei Elternschaften) eine leere Shell und bringt auf
einer reinen Informationsseite keinerlei Gegenwert. Static SSR liefert fertiges
HTML, braucht kein JavaScript und kostet im Container rund 60 MB RAM.
**Konsequenz:** Interaktivität ist später pro Komponente nachrüstbar
(`@rendermode InteractiveServer` an genau der einen Komponente), ohne die
Architektur zu ändern.
**Verworfene Alternativen:**
| Alternative | Warum nicht |
|---|---|
| Blazor WASM | Payload, SEO, Link-Vorschauen, kein Nutzen |
| ASP.NET Core MVC/Razor Pages | Funktioniert genauso, aber Razor Components sind das modernere Modell |
| Statiq.Web (C#-SSG) → nginx | Ops-technisch am schlanksten (nichts zu patchen), aber jede Textänderung erzwingt einen Build-Lauf. Bleibt als Rückfallebene. |
| Astro/Hugo | Ausgereifteres SSG-Ökosystem, aber fremdes Terrain |
### AE-2: Inhalte als Dateien im Repo, nicht in einer Datenbank
**Entscheidung:** Seiteninhalte als Markdown mit YAML-Frontmatter, Termine als
strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter `wwwroot`.
**Begründung:** Kein DB-Backup, kein Migrationsschema, keine Konsistenzprobleme
zwischen Dateien und Datenbank. Änderungen sind Commits und damit nachvollziehbar
und rückrollbar. Für eine Seite mit ~10 Unterseiten und ein paar Terminen pro Jahr
ist alles andere Overhead.
**Konsequenz:** Textänderungen erfordern einen Commit und ein Redeploy. Das ist bei
erwarteten fünf Änderungen im Jahr akzeptabel — und der Grund, warum Abschnitt 11
den Ausbau zum Web-Editor als eigene Stufe führt.
### AE-3: Inhalte werden ins Image gebacken, nicht als Volume gemountet
**Entscheidung:** `Content/` und `wwwroot/` sind Teil des Images.
**Begründung:** Gemountete Inhalte aus dem Appdata-Share ließen sich zwar direkt
am NAS editieren, aber genau dann driften Repo und Live-Stand auseinander — und
die Eigenschaft „Backup = `git clone`" aus AE-2 wäre wertlos.
**Konsequenz:** Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über
einen Commit behoben.
### AE-4: TLS und Zertifikate ausschließlich im Nginx Proxy Manager
**Entscheidung:** Der Container spricht nur HTTP auf Port 8080 und hat **kein**
Port-Mapping nach außen. NPM terminiert TLS und ist der einzige Weg zum Container.
**Begründung:** Entspricht dem bereits etablierten Muster im Home-Lab
(`cloud.anticarnist.de`). Zertifikatsverwaltung bleibt an einer Stelle.
**Konsequenz (wichtig):** In `Program.cs` **kein** `UseHttpsRedirection()` und
**kein** `UseHsts()` — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM.
---
## 4. Projekt anlegen (Rider)
Template **Blazor Web App** mit diesen Optionen:
| Option | Wert |
|---|---|
| Framework | .NET 10.0 |
| Authentication | None |
| Interactive render mode | **None** |
| Include sample pages | aus |
| Configure for HTTPS | an (nur für lokale Entwicklung relevant) |
| Do not use top-level statements | egal |
| Enlist in .NET Aspire orchestration | **aus** |
Projektname: `Elternbeirat.Web`, Solution: `Elternbeirat`.
Wichtig ist allein *Interactive render mode = None* — damit erzeugt das Template
kein `.Client`-Projekt und kein WebAssembly-Bundle.
**Angelegt und bestätigt (2026-09-20):** Blazor Web App, `net10.0`, Interactive
render mode `None`, Auth `None`, Sample pages aus, Docker-Optionen im Dialog aus
(Dockerfile schreiben wir selbst, siehe Abschnitt 7). Git-Repository beim
Anlegen mit erzeugt. Solution liegt direkt unter
`RiderProjects\Elternbeirat\`, das Projekt in `Elternbeirat.Web\` darunter —
**kein** `src/`-Zwischenverzeichnis, anders als ursprünglich in Abschnitt 5
skizziert.
NuGet-Pakete, die dazukommen:
- `Markdig` — Markdown-Rendering
- `YamlDotNet` — Frontmatter und `termine.yml`
---
## 5. Repo-Struktur
```
Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
├── Elternbeirat.sln
├── plan.md ← dieses Dokument
├── CLAUDE.md ← minimal: Trigger, nicht-offensichtliche Kommandos
├── docs/
│ ├── deployment.md ← Unraid, NPM, Registry, Rollback
│ ├── dns.md ← Strato, DynDNS, Mail-Records
│ ├── inhalte-pflegen.md ← Anleitung für den Nicht-Alltagsfall
│ ├── inhalte-migration.md ← Übernahme aus dem alten WordPress
│ └── recht.md ← Impressum, Datenschutz, Fotos
├── Elternbeirat.Web/
│ ├── Elternbeirat.Web.csproj
│ ├── Components/
│ │ ├── App.razor
│ │ ├── Routes.razor
│ │ ├── Layout/ MainLayout, NavMenu, Footer
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
│ │ News, NewsBeitrag, Kontakt,
│ │ Impressum, Datenschutz, Fehler404
│ ├── Content/ ← Inhalte, kein Code
│ │ ├── seiten/*.md
│ │ ├── news/2026-09-20-titel.md
│ │ └── termine.yml
│ ├── Services/
│ │ ├── MarkdownContentService.cs
│ │ ├── TermineService.cs
│ │ └── IcsWriter.cs
│ ├── wwwroot/
│ │ ├── css/site.css ← eigenes CSS, keine CDN-Einbindung
│ │ ├── img/
│ │ └── dokumente/ ← Protokolle, Satzung (PDF)
│ └── Program.cs
├── Elternbeirat.Web.Tests/ ← Smoke-Tests: jede Route liefert 200
├── Dockerfile
├── compose.yaml
├── .dockerignore
└── .gitea/workflows/deploy.yml
```
`CLAUDE.md` bleibt bewusst kurz (Build-/Run-Kommandos, Stilregeln, Verweis auf
`docs/`). Die Details liegen in `docs/` und werden nur bei Bedarf gelesen.
---
## 6. Inhaltsmodell
**Seite** (`Content/seiten/ueber-uns.md`):
```markdown
---
titel: Über uns
slug: ueber-uns
reihenfolge: 20
beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten.
---
## Der Elternbeirat
Fließtext …
```
**Termin** (`Content/termine.yml`):
```yaml
- titel: Elternbeiratssitzung
beginn: 2026-10-14T19:30:00
ende: 2026-10-14T21:00:00
ort: IGMH, Raum A103
oeffentlich: true
notiz: Gäste willkommen
```
**News-Beitrag** (`Content/news/2026-09-20-neue-website.md`) — wie Seite, plus
`datum` und `autor`.
Die Services lesen beim Start alles ein, cachen es im Speicher und stellen es
typisiert bereit. In Development zusätzlich ein `FileSystemWatcher`, damit
Textänderungen ohne Neustart sichtbar werden.
**Zusatznutzen ohne Mehraufwand:** ein ICS-Endpoint unter `/termine.ics`, der die
öffentlichen Termine ausliefert. Eltern abonnieren den Kalender einmal im Handy
und sehen jede Sitzung automatisch. Das ist der eine Punkt, an dem die Eigenbau-
Lösung die alte WordPress-Seite spürbar schlägt.
---
## 7. Build und Deployment
### Stufe 1 — Handbetrieb (Start hier)
```bash
docker compose build
docker compose up -d
```
Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre
Selbstzweck.
### Stufe 2 — Gitea Actions (Runner ist vorhanden)
`.gitea/workflows/deploy.yml` auf Push nach `main`:
1. `actions/checkout`
2. Login an der Gitea-eigenen Container-Registry
3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **und** `:${{ gitea.sha }}`
Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten:
- Der `act_runner` im Docker-Modus braucht Zugriff auf einen Docker-Socket oder
einen DinD-Service, sonst schlägt `build-push-action` fehl.
- Die Gitea-Registry braucht ein Paket-Token mit Schreibrecht (`write:package`),
nicht das normale Login-Passwort.
**Immer auch den SHA-Tag pushen.** `:latest` allein macht Rollback unmöglich.
### Redeploy auf Unraid
Watchtower, aber **label-scoped** — sonst aktualisiert er ungefragt den ganzen
Home-Lab-Bestand:
```yaml
# im Watchtower-Container
WATCHTOWER_LABEL_ENABLE: "true"
```
```yaml
# im eb-web-Service
labels:
com.centurylinklabs.watchtower.enable: "true"
```
Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser
Änderungsfrequenz völlig legitim.
### Dockerfile (Skizze)
```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Elternbeirat.Web/Elternbeirat.Web.csproj Elternbeirat.Web/
RUN dotnet restore Elternbeirat.Web/Elternbeirat.Web.csproj
COPY . .
RUN dotnet publish Elternbeirat.Web/Elternbeirat.Web.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"]
```
Das chiseled-Image läuft ab .NET 8 standardmäßig als non-root (UID 1654) und
hört auf Port 8080. **Es enthält keine Shell** — ein `HEALTHCHECK` mit `curl`
funktioniert dort nicht. Entweder das normale `aspnet:10.0-noble` verwenden oder
die Überwachung NPM bzw. Uptime Kuma überlassen.
---
## 8. Hosting auf Unraid
```yaml
# compose.yaml
services:
eb-web:
image: gitea.<domain>/tom/elternbeirat:latest
container_name: eb-web
restart: unless-stopped
environment:
ASPNETCORE_URLS: http://+:8080
TZ: Europe/Berlin
networks: [npm]
labels:
com.centurylinklabs.watchtower.enable: "true"
networks:
npm:
external: true
```
Kein `ports:`-Block. Der Container ist ausschließlich über das NPM-Docker-Netz
erreichbar.
**NPM Proxy Host:**
| Feld | Wert |
|---|---|
| Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` |
| Scheme | `http` |
| Forward Hostname | `eb-web` |
| Forward Port | `8080` |
| Block Common Exploits | an |
| Websockets Support | aus (wird bei statischem SSR nicht gebraucht) |
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
**In `Program.cs` nicht vergessen:**
```csharp
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
// NPM ist der einzige Weg zum Container (kein Port-Mapping),
// daher ist das Leeren der Allowlists hier vertretbar:
KnownNetworks = { }, KnownProxies = { }
});
```
Ohne das sieht die App jede Anfrage als HTTP und mit der Proxy-IP statt der
Client-IP — relevant für korrekte absolute URLs und für die Logs.
---
## 9. Inhalte — zeitkritisch
Die alte Seite ist offline, Maiks Strato-Vertrag läuft aber noch. Solange er läuft,
ist der Webspace erreichbar; nach der Kündigung ist der Bestand endgültig weg.
**Reihenfolge der Rettungsversuche:**
1. Prüfen, was beim Termin mit Maik tatsächlich gesichert wurde (Dateien? MySQL-Dump?
Beides?). Ein vollständiger Dump wäre der Idealfall — daraus lassen sich Texte,
Seitenstruktur, Medien und PDFs sauber extrahieren.
2. Falls nur Dateien vorliegen: `wp-content/uploads` enthält Bilder und PDFs, die
Texte liegen aber in der Datenbank. Dann Schritt 3.
3. Wayback Machine auf Snapshots von `elternbeirat-igmh.de` prüfen.
4. Falls nichts davon greift: Maik bitten, vor Vertragsende noch einen Export zu
ziehen — oder Inhalte aus den Protokollen und von der Schulseite neu aufbauen.
**Diese Frage blockiert den Inhaltsteil, nicht den Technikteil.** Das Gerüst lässt
sich mit Platzhaltern bauen und später befüllen.
---
## 10. Rechtliches
Kein Rechtsrat — aber die Punkte, an denen Schulseiten regelmäßig auffallen:
- **Impressum (§ 5 DDG):** Hat der Elternbeirat keine eigene Rechtsform, steht der
Betreiber persönlich mit Name und ladungsfähiger Anschrift im Impressum. Das ist
eine Entscheidung, keine Formalie — die Privatadresse wird damit öffentlich.
Alternative: Anschrift der Schule, aber nur mit deren ausdrücklichem Einverständnis
und wenn die Schule Mitbetreiberin ist.
- **Datenschutzerklärung:** Mit dem Umzug ist Tom Verantwortlicher im Sinne der DSGVO.
Server-Logs mit IP-Adressen benennen, Rechtsgrundlage und Löschfrist festlegen.
- **Keine externen Ressourcen.** Google Fonts, Maps, YouTube-Embeds und CDN-Skripte
übertragen die IP der Besucher an Dritte. Fonts werden selbst ausgeliefert.
- **Fotos von Kindern:** nur mit Einwilligung der Erziehungsberechtigten — bei
Schulseiten der mit Abstand häufigste Fehler. Im Zweifel keine Personenfotos.
- Hosting am privaten Anschluss bedeutet: Die öffentliche IP des Privatanschlusses
steht im DNS einer Schulseite. Bewusste Entscheidung, kein Nebeneffekt.
---
## 11. Offene Punkte
| # | Punkt | Status |
|---|---|---|
| 1 | Was wurde vom alten WordPress gesichert? | **offen, zeitkritisch** |
| 2 | Kann „STRATO Mail Plus" DynDNS? Laut Strato-FAQ ab „PowerWeb Basic 2013 bzw. STRATO Domain" — ob Mail Plus dazuzählt, ist unklar. Beim Support mit anfragen, solange der Umzugsvorgang läuft. | offen |
| 3 | DynDNS-Updater: Fritzbox (kennt als Exposed-Host-Vorschaltgerät die öffentliche IP) oder ddclient-Container auf Unraid? | offen |
| 4 | Sollen andere Beiratsmitglieder Inhalte selbst pflegen können? Falls ja: eigener Ausbauschritt (Decap CMS auf Git-Basis oder kleines Admin-UI). | offen |
| 5 | Kontaktformular gewünscht? Würde Server-Interaktivität und Spam-Schutz erfordern — `mailto:` ist die aufwandsfreie Alternative. | offen |
| 6 | Wer springt ein, wenn Tom nicht verfügbar ist? Eine Seite, die nur einer deployen kann, ist eine Abhängigkeit, die der Beirat kennen sollte. | offen |
| 7 | Formulare bei Strato einreichen (unterschrieben, liegt bereit) | offen |
---
## 12. Umsetzungsreihenfolge
| # | Schritt | Abhängig von |
|---|---|---|
| 1 | Strato-Formulare einreichen, Domainumzug anstoßen | — |
| 2 | Inhaltslage klären (Abschnitt 9) | — |
| 3 | Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen | — |
| 4 | Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint | 3 |
| 5 | Layout, Navigation, Seiten mit Platzhaltern | 4 |
| 6 | Dockerfile, compose.yaml, lokal gegen NPM testen | 5 |
| 7 | Echte Inhalte einpflegen | 2, 5 |
| 8 | Impressum und Datenschutzerklärung | 7 |
| 9 | DNS umstellen, NPM Proxy Host, Let's Encrypt | 1, 6 |
| 10 | Gitea Actions + Registry + Watchtower | 6, 9 |
Schritte 1 und 2 laufen unabhängig vom Code und sollten sofort starten —
Schritt 2 ist der einzige, bei dem Warten echten Schaden anrichtet.