diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..abc026f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +Website des Elternbeirats der IGMH. Blazor Web App mit statischem +Server-Side-Rendering, .NET 10. Läuft als Container auf Unraid hinter dem Nginx +Proxy Manager. + +Architekturentscheidungen und deren Begründung: `plan.md`. Dort nachlesen, bevor +eine davon in Frage gestellt wird. + +## Wann welches Dokument + +| Thema | Datei | +|---|---| +| Warum SSR statt WASM, warum keine DB, offene Punkte, Meilensteine | `plan.md` | +| Unraid, compose, NPM-Proxy-Host, Registry, Rollback | `docs/deployment.md` | +| Strato, DynDNS, Mail-Records | `docs/dns.md` | +| Inhalte anlegen und ändern | `docs/inhalte-pflegen.md` | +| Impressum, Datenschutz, Fotos | `docs/recht.md` | + +## Kommandos + +```bash +dotnet run --project Elternbeirat.Web # lokal, Content-Reload per FileSystemWatcher +dotnet test # Smoke-Tests: jede Route liefert 200 +docker compose build && docker compose up -d +``` + +Es gibt keine Datenbank, keine Migrationen, kein Seeding. Ein `git clone` ist der +vollständige Stand. + +## Nicht offensichtlich + +- **Inhalte liegen im Image**, nicht in einem Volume. Jede Textänderung braucht + Commit und Rebuild. Das ist Absicht (`plan.md`, AE-3) — nicht „vereinfachen". +- **Kein `UseHttpsRedirection()`, kein `UseHsts()`.** NPM terminiert TLS; beides + erzeugt hinter dem Proxy eine Redirect-Schleife. `UseForwardedHeaders` mit + geleerten `KnownNetworks`/`KnownProxies` ist korrekt so, weil der Container kein + Port-Mapping hat und nur über NPM erreichbar ist. +- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder + `sh` schlägt dort fehl. +- Neue Seite = Markdown in `Content/seiten/`. Neuer Termin = Eintrag in + `Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst. +- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`). + +## Regeln + +- **Statisches SSR ist der Default.** Kein `@rendermode` ohne konkreten Anlass, + und wenn, dann an genau der einen Komponente — nie global. +- **Keine externen Ressourcen.** Keine CDN-Skripte, keine Google Fonts, keine + Maps- oder Video-Embeds. Schriften werden selbst ausgeliefert. Grund ist + Datenschutz, nicht Geschmack (`docs/recht.md`). +- **Keine Datenbank, kein EF Core, kein ORM.** Inhalte sind Dateien. +- **Inhalte gehören nach `Content/`**, nicht als Markup in Komponenten. +- Kein CMS, kein Login, keine Benutzerkonten. +- UI-Texte auf Deutsch, ohne Anglizismen. `Termine`, nicht `Events`. +- Neue NuGet-Abhängigkeiten vorher begründen. Bestand: `Markdig`, `YamlDotNet`. + +## Stil + +- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces. +- Services über DI, als Singleton registriert (Inhalte werden beim Start + eingelesen und gecacht). +- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc, Razor-Markup + nicht. +- Fachbegriffe im Code auf Deutsch, wenn sie Domänenbegriffe sind + (`Termin`, `Protokoll`, `Beitrag`) — Framework-Begriffe bleiben englisch. diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..02f3b84 --- /dev/null +++ b/plan.md @@ -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./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./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.