Build the content pipeline: pages, posts, events, layout #2

Merged
Tom merged 9 commits from feature/content-pipeline into main 2026-09-21 16:33:59 +02:00
15 changed files with 532 additions and 540 deletions
Showing only changes of commit c4c7ca2ee4 - Show all commits

No files matched your search

+7 -5
View File
@@ -38,7 +38,7 @@ vollständige Stand.
Port-Mapping hat und nur über NPM erreichbar ist. Port-Mapping hat und nur über NPM erreichbar ist.
- **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder - **Das chiseled-Runtime-Image hat keine Shell.** `HEALTHCHECK` mit `curl` oder
`sh` schlägt dort fehl. `sh` schlägt dort fehl.
- Neue Seite = Markdown in `Content/seiten/`. Neuer Termin = Eintrag in - Neue Seite = Markdown in `Content/pages/`. Neuer Termin = Eintrag in
`Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst. `Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst.
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`). - Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
@@ -60,7 +60,9 @@ vollständige Stand.
- Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces. - Nullable aktiviert, `ImplicitUsings` an, File-scoped Namespaces.
- Services über DI, als Singleton registriert (Inhalte werden beim Start - Services über DI, als Singleton registriert (Inhalte werden beim Start
eingelesen und gecacht). eingelesen und gecacht).
- Öffentliche Typen und Methoden der `Services` bekommen XML-Doc, Razor-Markup - Öffentliche Typen und Methoden der `Services` bekommen XML-Doc (auf Englisch,
nicht. leicht verständlich), Razor-Markup nicht.
- Fachbegriffe im Code auf Deutsch, wenn sie Domänenbegriffe sind - **Code auf Englisch** — Typen, Member, Variablen, Kommentare, Skripte und Doku.
(`Termin`, `Protokoll`, `Beitrag`) — Framework-Begriffe bleiben englisch. Ausnahme: UI-Texte und Redakteurs-Inhalte bleiben Deutsch (Markdown-Inhalte,
Frontmatter-Schlüssel wie `titel:`, sowie Slugs/Dateinamen unter `Content/`,
z. B. `vorstandsteam`).
@@ -0,0 +1,37 @@
@page "/{Slug}"
@using Elternbeirat.Web.Services
@inject PageService PageService
@if (_page is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_page.Title</PageTitle>
<article>
@((MarkupString)_page.ContentHtml)
</article>
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private Page? _page;
protected override void OnParametersSet()
{
_page = PageService.Find(Slug);
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
// /not-found page instead of an empty 200 response.
if (_page is null && HttpContext is not null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
@@ -1,41 +0,0 @@
@page "/{Slug}"
@using Elternbeirat.Web.Services
@inject SeitenService SeitenService
@if (_seite is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_seite.Titel</PageTitle>
<article>
@((MarkupString)_seite.InhaltHtml)
</article>
}
@code {
[Parameter]
public string Slug { get; set; } = "";
private Seite? _seite;
protected override void OnParametersSet()
{
_seite = SeitenService.Finde(Slug);
// Unbekannter Slug -> 404, damit UseStatusCodePagesWithReExecute die
// /not-found-Seite ausliefert (statt einer leeren 200er-Antwort).
if (_seite is null)
{
var kontext = HttpContext;
if (kontext is not null)
{
kontext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
+2 -2
View File
@@ -11,8 +11,8 @@
<PackageReference Include="Markdig" Version="0.38.0" /> <PackageReference Include="Markdig" Version="0.38.0" />
</ItemGroup> </ItemGroup>
<!-- Inhalte liegen als Dateien im Image (plan.md AE-3), nicht in einem <!-- Content ships as files inside the image, not in a volume. Copy it to
Volume. Ins Ausgabeverzeichnis kopieren, damit der Container sie findet. --> the output directory so the container can find it. -->
<ItemGroup> <ItemGroup>
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" /> <Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
+9 -10
View File
@@ -7,16 +7,16 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// Inhalte werden beim Start einmalig eingelesen und gecacht -> Singleton. // Content is read once at startup and cached -> singleton.
builder.Services.AddSingleton<SeitenService>(); builder.Services.AddSingleton<PageService>();
var app = builder.Build(); var app = builder.Build();
// NPM terminiert TLS und ist der einzige Weg zum Container (kein Port-Mapping // NPM terminates TLS and is the only way to reach the container (no port
// im Produktivbetrieb, siehe plan.md AE-4). Ohne UseForwardedHeaders sieht die // mapping in production). Without UseForwardedHeaders the app sees every request
// App jede Anfrage als HTTP und mit der Proxy-IP statt der Client-IP. // as HTTP and with the proxy IP instead of the client IP.
// KnownNetworks/KnownProxies bewusst geleert, weil ausschliesslich NPM den // KnownNetworks/KnownProxies are deliberately empty because only NPM reaches
// Container erreicht. // the container.
app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseForwardedHeaders(new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
@@ -28,9 +28,8 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
// Kein UseHsts() und kein UseHttpsRedirection(): NPM setzt HSTS und // No UseHsts() and no UseHttpsRedirection(): NPM sets HSTS and terminates
// terminiert TLS. Beides hier wuerde hinter dem Proxy eine // TLS. Both here would create a redirect loop behind the proxy.
// Redirect-Schleife erzeugen (plan.md AE-4).
} }
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
+25
View File
@@ -0,0 +1,25 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A static content page, read from a Markdown file in <c>Content/pages/</c>.
/// The file name (without extension) is the slug.
/// </summary>
public sealed class Page
{
/// <summary>
/// URL identifier of the page, taken from the file name without umlauts
/// (e.g. <c>vorstandsteam</c>). It appears in the route <c>/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Display title from the YAML front matter (<c>titel:</c>). Falls back to
/// the slug when no title is set.
/// </summary>
public required string Title { get; init; }
/// <summary>
/// The page body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
+86
View File
@@ -0,0 +1,86 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the static content pages from <c>Content/pages/*.md</c>, renders them
/// to HTML once at startup and keeps them in memory. Registered as a singleton
/// because the content lives in the image and does not change at runtime.
/// </summary>
public sealed class PageService
{
private readonly IReadOnlyDictionary<string, Page> _pagesBySlug;
/// <summary>
/// Reads every Markdown page from the <c>Content/pages</c> directory below
/// the application root and caches it.
/// </summary>
public PageService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "pages");
var pages = new Dictionary<string, Page>(StringComparer.OrdinalIgnoreCase);
if (Directory.Exists(directory))
{
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
{
var page = Read(path, pipeline);
pages[page.Slug] = page;
}
}
_pagesBySlug = pages;
}
/// <summary>
/// Returns the page for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
public Page? Find(string slug) =>
_pagesBySlug.GetValueOrDefault(slug);
private static Page Read(string path, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(path);
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = ReadTitle(document, source) ?? slug;
var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html };
}
/// <summary>
/// Pulls the <c>titel:</c> value out of the YAML front matter. Kept
/// deliberately simple (a single <c>key: value</c> line) — YamlDotNet will
/// take over once we need structured metadata. The key stays German because
/// the Markdown files are edited by German-speaking editors.
/// </summary>
private static string? ReadTitle(MarkdownDocument document, string source)
{
var block = document.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
if (block is null)
{
return null;
}
var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
foreach (var line in frontMatter.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("titel:", StringComparison.OrdinalIgnoreCase))
{
return trimmed["titel:".Length..].Trim().Trim('"');
}
}
return null;
}
}
-25
View File
@@ -1,25 +0,0 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// Eine statische Inhaltsseite, eingelesen aus einer Markdown-Datei in
/// <c>Content/seiten/</c>. Der Dateiname (ohne Endung) ist der Slug.
/// </summary>
public sealed class Seite
{
/// <summary>
/// URL-Kennung der Seite, abgeleitet aus dem Dateinamen ohne Umlaute
/// (z. B. <c>vorstandsteam</c>). Erscheint in der Route <c>/{slug}</c>.
/// </summary>
public required string Slug { get; init; }
/// <summary>
/// Anzeigetitel aus dem YAML-Frontmatter (<c>titel:</c>). Fällt auf den
/// Slug zurück, wenn kein Titel gesetzt ist.
/// </summary>
public required string Titel { get; init; }
/// <summary>
/// Der aus Markdown gerenderte HTML-Rumpf der Seite (ohne Frontmatter).
/// </summary>
public required string InhaltHtml { get; init; }
}
@@ -1,86 +0,0 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Liest die statischen Inhaltsseiten aus <c>Content/seiten/*.md</c> ein,
/// rendert sie beim Start einmalig zu HTML und hält sie im Speicher.
/// Als Singleton registriert — Inhalte liegen im Image und ändern sich zur
/// Laufzeit nicht (plan.md AE-3).
/// </summary>
public sealed class SeitenService
{
private readonly IReadOnlyDictionary<string, Seite> _seitenNachSlug;
/// <summary>
/// Liest alle Markdown-Seiten aus dem Verzeichnis <c>Content/seiten</c>
/// unterhalb des Anwendungsstamms ein und cached sie.
/// </summary>
public SeitenService(IWebHostEnvironment umgebung)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var verzeichnis = Path.Combine(umgebung.ContentRootPath, "Content", "seiten");
var seiten = new Dictionary<string, Seite>(StringComparer.OrdinalIgnoreCase);
if (Directory.Exists(verzeichnis))
{
foreach (var pfad in Directory.EnumerateFiles(verzeichnis, "*.md"))
{
var seite = Lies(pfad, pipeline);
seiten[seite.Slug] = seite;
}
}
_seitenNachSlug = seiten;
}
/// <summary>
/// Liefert die Seite zum angegebenen Slug oder <c>null</c>, wenn es keine
/// gibt. Groß-/Kleinschreibung wird ignoriert.
/// </summary>
public Seite? Finde(string slug) =>
_seitenNachSlug.GetValueOrDefault(slug);
private static Seite Lies(string pfad, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(pfad);
var quelle = File.ReadAllText(pfad);
var dokument = Markdown.Parse(quelle, pipeline);
var titel = LiesTitel(dokument, quelle) ?? slug;
var html = Markdown.ToHtml(quelle, pipeline);
return new Seite { Slug = slug, Titel = titel, InhaltHtml = html };
}
/// <summary>
/// Zieht den <c>titel:</c>-Wert aus dem YAML-Frontmatter. Bewusst simpel
/// gehalten (eine Zeile <c>schlüssel: wert</c>) — für strukturiertere
/// Metadaten kommt später YamlDotNet.
/// </summary>
private static string? LiesTitel(MarkdownDocument dokument, string quelle)
{
var block = dokument.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
if (block is null)
{
return null;
}
var frontmatter = quelle.Substring(block.Span.Start, block.Span.Length);
foreach (var zeile in frontmatter.Split('\n'))
{
var getrimmt = zeile.Trim();
if (getrimmt.StartsWith("titel:", StringComparison.OrdinalIgnoreCase))
{
return getrimmt["titel:".Length..].Trim().Trim('"');
}
}
return null;
}
}
+11 -11
View File
@@ -1,16 +1,16 @@
# TEST-Setup fuer den ersten Lauf auf Unraid (ohne NPM). # TEST setup for the first run on Unraid (without NPM).
# #
# Zieht das fertige Image aus der Gitea-Registry (Build passiert auf dem # Pulls the finished image from the Gitea registry (the build happens on the
# Entwicklungsrechner, siehe docs/deployment.md), statt auf Unraid aus dem # development machine, see docs/deployment.md) instead of building from source
# Quellcode zu bauen. # on Unraid.
# #
# Weicht bewusst vom Produktiv-Setup in plan.md Abschnitt 8 ab: # Deliberately differs from the production setup:
# - Es gibt ein Port-Mapping (5000 aussen -> 8080 innen), damit die App im # - There is a port mapping (5000 outside -> 8080 inside) so the app is
# lokalen Netz unter http://<unraid>:5000 erreichbar ist. Im Produktiv- # reachable in the local network at http://<unraid>:5000. In production the
# betrieb entfaellt das Mapping; dort ist nur NPM der Weg zum Container # mapping is dropped; there NPM is the only path to the container (use the
# (dann stattdessen das externe npm-Netz, siehe docs/deployment.md). # external npm network instead, see docs/deployment.md).
# #
# Sobald NPM steht, wird diese Datei durch das Produktiv-compose ersetzt. # Once NPM is in place, this file is replaced by the production compose.
services: services:
eb-web: eb-web:
@@ -21,4 +21,4 @@ services:
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
TZ: Europe/Berlin TZ: Europe/Berlin
ports: ports:
- "5000:8080" # TEST-Zugang, im Produktivbetrieb entfernen - "5000:8080" # TEST access, remove in production
+87 -90
View File
@@ -1,122 +1,120 @@
# Deployment # Deployment
Wie ein neuer Stand der Website auf Unraid landet. Aktueller Stand: How a new version of the website ends up on Unraid. Current state:
**Handbetrieb** — Image lokal bauen, in die Gitea-Registry pushen, auf Unraid **manual** — build the image locally, push it to the Gitea registry, pull it on
ziehen. Die Automatisierung per Gitea Actions (plan.md Schritt 10) kommt später. Unraid. Automation via Gitea Actions comes later.
> **Test- vs. Produktivbetrieb.** Solange die Domain noch nicht umgezogen ist und > **Test vs. production.** As long as the domain has not moved yet and NPM is not
> NPM nicht davorsteht, läuft der Container mit einem Port-Mapping und ist im > in front of it, the container runs with a port mapping and is directly reachable
> lokalen Netz direkt erreichbar (`http://<unraid>:5000`). Im Produktivbetrieb > on the local network (`http://<unraid>:5000`). In production the mapping is gone
> entfällt das Mapping — dann ist nur NPM der Weg zum Container (plan.md AE-4, > — then NPM is the only path to the container. The two compose variants are
> Abschnitt 8). Die beiden compose-Varianten sind unten getrennt beschrieben. > described separately below.
--- ---
## Voraussetzungen (einmalig) ## Prerequisites (one-time)
### Gitea-Registry-Token ### Gitea registry token
Der Push in die Registry braucht ein Gitea-Zugriffstoken mit **Paket-Schreibrecht** The push to the registry needs a Gitea access token with **package write
(`package: Read and Write`) — **nicht** das Kontopasswort. permission** (`package: Read and Write`) — **not** the account password.
1. Gitea → oben rechts Profilbild → **Settings** → **Applications**. 1. Gitea → top right profile picture → **Settings** → **Applications**.
2. Abschnitt **Manage Access Tokens**: Name vergeben (z.B. `registry-push`). 2. **Manage Access Tokens** section: assign a name (e.g. `registry-push`).
3. Unter **Select scopes**: `package` auf **Read and Write** stellen. 3. Under **Select scopes**: set `package` to **Read and Write**.
4. **Generate Token** klicken, Zeichenkette **sofort kopieren** (nur einmal 4. Click **Generate Token**, **copy the string immediately** (shown only once).
sichtbar).
> **Token ist ein Geheimnis.** Niemals in Git, Chats, Screenshots oder Tickets > **The token is a secret.** Never store it in plain text in Git, chats,
> im Klartext ablegen. Wird eins doch einmal sichtbar: in Gitea **löschen** und > screenshots, or tickets. If one does become visible: **delete** it in Gitea and
> neu erzeugen. Ein `package`-Token erlaubt das Hochladen beliebiger Images in > generate a new one. A `package` token allows uploading arbitrary images to the
> die Registry. > registry.
Der Docker-Login speichert das Token danach lokal, sodass es nur einmal The Docker login then stores the token locally, so it only has to be entered once.
eingegeben werden muss.
--- ---
## Arbeitsweise: Branches, nie direkt auf main ## Way of working: branches, never directly on main
`main` ist der **veröffentlichte** Stand — nur daraus wird `:latest` gebaut, und `main` is the **published** state — only `:latest` is built from it, and only
nur `:latest` zieht Unraid. Deshalb wird nie direkt auf `main` committet: `:latest` is pulled by Unraid. That is why we never commit directly to `main`:
1. Feature-Branch anlegen: `git switch -c <bereich>/<kurz>` (z.B. 1. Create a feature branch: `git switch -c <area>/<short>` (e.g.
`deployment/sha-tagging`, `content/pipeline`). `deployment/sha-tagging`, `content/pipeline`).
2. Dort committen, Branch pushen: `git push -u origin <branch>`. 2. Commit there, push the branch: `git push -u origin <branch>`.
3. In Gitea einen **Pull Request** gegen `main` öffnen und dort mergen. 3. Open a **Pull Request** against `main` in Gitea and merge it there.
4. Erst danach aus `main` das Release-Image bauen (unten). 4. Only then build the release image from `main` (below).
**Dev-Images bleiben lokal.** Zum Ausprobieren auf dem eigenen Rechner: **Dev images stay local.** For trying things out on your own machine:
```bash ```bash
scripts/dev-build.sh # baut elternbeirat-web:dev-<branch>, pusht NICHTS scripts/dev-build.sh # builds elternbeirat-web:dev-<branch>, pushes NOTHING
``` ```
So kann ein Entwicklungsstand nie versehentlich als `:latest` in der Registry This way a development state can never accidentally land in the registry as
landen. `:latest`.
## Neuen Stand ausrollen (Handbetrieb) ## Rolling out a new version (manual)
Voraussetzung: einmalig an der Registry angemeldet (siehe unten). Dann, **auf Prerequisite: logged in to the registry once (see below). Then, **on main** and
main** und mit sauberem Arbeitsverzeichnis: with a clean working directory:
```bash ```bash
scripts/release.sh scripts/release.sh
``` ```
Das Skript baut das Image, taggt es mit `:latest` **und** dem Commit-Kurz-SHA The script builds the image, tags it with `:latest` **and** the short commit SHA
(für Rollback) und pusht beide. Es **bricht ab**, wenn du nicht auf `main` bist (for rollback), and pushes both. It **aborts** if you are not on `main` or have
oder uncommittete Änderungen hast, und warnt bei ungepushten Commits. uncommitted changes, and warns about unpushed commits.
> Der Image-Pfad `gitea.anticarnist.de/tom/elternbeirat` ist **kleingeschrieben** > The image path `gitea.anticarnist.de/tom/elternbeirat` is **lowercase** —
> — Container-Registries verlangen das im Pfad, obwohl Benutzer (`Tom`) und Repo > container registries require that in the path, even though the user (`Tom`) and
> (`Elternbeirat`) großgeschrieben sind. > the repo (`Elternbeirat`) are capitalized.
### Unraid einmalig an der Registry anmelden ### Logging Unraid in to the registry once
Das Image ist **privat**, deshalb muss Unraid sich einmal anmelden, bevor es The image is **private**, so Unraid has to log in once before it can pull. The
ziehen kann. Das **Compose Manager Plus**-Plugin hat dafür kein UI-Feld — der **Compose Manager Plus** plugin has no UI field for this — the login runs through
Login läuft über das Unraid-Terminal (`>_`-Symbol oben rechts in der the Unraid terminal (`>_` symbol at the top right of the web interface, prompt
Weboberfläche, Prompt `root@Cube:~#`): `root@Cube:~#`):
```bash ```bash
docker login gitea.anticarnist.de docker login gitea.anticarnist.de
# Username: Tom # Username: Tom
# Password: <package-Token> # Password: <package token>
``` ```
Der Login bleibt gespeichert; er muss nur wiederholt werden, wenn das Token The login stays stored; it only has to be repeated when the token changes. Two
wechselt. Zwei erfahrungsgemäße Stolpersteine: pitfalls learned from experience:
- **Nicht mit PowerShell/Laptop verwechseln.** Der Login muss im *Unraid*-Terminal - **Don't confuse it with PowerShell/laptop.** The login has to happen in the
passieren (`root@Cube`), nicht in der Windows-PowerShell (`PS C:\`). Der Laptop *Unraid* terminal (`root@Cube`), not in Windows PowerShell (`PS C:\`). The laptop
braucht den Login nur zum *Pushen*, Unraid zum *Ziehen*. needs the login only to *push*, Unraid to *pull*.
- **Falscher Username bleibt hängen.** Meldet der Login „Stored credentials - **A wrong username gets stuck.** If the login reports "Stored credentials
invalid or expired" und fragt *nicht* nach dem Namen, erst `docker logout invalid or expired" and does *not* ask for the name, first `docker logout
gitea.anticarnist.de`, dann neu einloggen — sonst wird versehentlich ein gitea.anticarnist.de`, then log in again — otherwise a nonsense username gets
Nonsens-Username gespeichert. stored by accident.
- **Klartext-Warnung.** Docker speichert das Token unverschlüsselt in - **Plain-text warning.** Docker stores the token unencrypted in
`/root/.docker/config.json`. Auf dem eigenen Server für den Anfang okay. `/root/.docker/config.json`. On your own server, okay for a start.
*Später sauberer:* einen Credential-Helper einrichten (→ offener Punkt unten). *Cleaner later:* set up a credential helper (→ open item below).
### Auf Unraid neu ziehen und starten ### Pull and start again on Unraid
Im **Compose Manager Plus**-Plugin (Unraid-Weboberfläche): In the **Compose Manager Plus** plugin (Unraid web interface):
- Reiter **Docker** → Abschnitt **Compose** → Stack **elternbeirat** → - **Docker** tab → **Compose** section → stack **elternbeirat** →
**Compose Down**, dann **Compose Up** (oder „Pull" + „Up", je nach **Compose Down**, then **Compose Up** (or "Pull" + "Up", depending on the plugin
Plugin-Version), damit die neue `:latest` gezogen wird. version), so that the new `:latest` is pulled.
> `docker compose up` zieht ein `:latest` **nicht** automatisch neu, wenn schon > `docker compose up` does **not** automatically re-pull a `:latest` if an image of
> ein gleichnamiges Image lokal liegt. Im Zweifel vorher explizit „Pull". > the same name is already present locally. When in doubt, explicitly "Pull" first.
--- ---
## compose-Varianten ## compose variants
### Test (jetzt: ohne NPM, direkt im LAN erreichbar) ### Test (now: without NPM, directly reachable on the LAN)
Liegt im Repo als `compose.yaml`. Zieht das Registry-Image und mappt Port Present in the repo as `compose.yaml`. Pulls the registry image and maps port
**5000 → 8080**: **5000 → 8080**:
```yaml ```yaml
@@ -129,15 +127,14 @@ services:
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
TZ: Europe/Berlin TZ: Europe/Berlin
ports: ports:
- "5000:8080" # TEST-Zugang, im Produktivbetrieb entfernen - "5000:8080" # TEST access, remove in production
``` ```
Erreichbar unter `http://<unraid>:5000`. Reachable at `http://<unraid>:5000`.
### Produktiv (später: nur über NPM) ### Production (later: only via NPM)
Kein `ports:`-Block, stattdessen das externe NPM-Docker-Netz (plan.md No `ports:` block, instead the external NPM Docker network:
Abschnitt 8):
```yaml ```yaml
services: services:
@@ -159,27 +156,27 @@ networks:
## Rollback ## Rollback
`scripts/release.sh` taggt jeden Release zusätzlich mit dem Commit-Kurz-SHA, der `scripts/release.sh` additionally tags each release with the short commit SHA,
sich — anders als das wandernde `:latest` — nie verschiebt. Zum Zurückrollen in which — unlike the moving `:latest` — never shifts. To roll back, replace
der Unraid-`compose.yaml` `:latest` durch `:<sha>` des letzten funktionierenden `:latest` in the Unraid `compose.yaml` with the `:<sha>` of the last working state
Stands ersetzen und neu hochfahren: and bring it back up:
```yaml ```yaml
image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest image: gitea.anticarnist.de/tom/elternbeirat:99dd052 # statt :latest
``` ```
Welche SHA-Tags in der Registry liegen, zeigt Gitea unter Which SHA tags are in the registry is shown by Gitea under
`Tom/-/packages` → `elternbeirat`. `Tom/-/packages` → `elternbeirat`.
--- ---
## Offene Automatisierung (später, plan.md Schritt 10) ## Open automation (later)
- `.gitea/workflows/deploy.yml`: auf Push nach `main` bauen und pushen. - `.gitea/workflows/deploy.yml`: build and push on push to `main`.
- Zwei bekannte Stolpersteine: der `act_runner` braucht Docker-Socket-Zugriff; - Two known pitfalls: the `act_runner` needs Docker socket access; the registry
die Registry braucht das `write:package`-Token, nicht das Login-Passwort. needs the `write:package` token, not the login password.
- Redeploy per Watchtower **label-scoped**, sonst aktualisiert er den ganzen - Redeploy via Watchtower **label-scoped**, otherwise it updates the entire
Home-Lab-Bestand. home-lab inventory.
- **Registry-Token auf Unraid verschlüsseln:** aktuell liegt es im Klartext in - **Encrypt the registry token on Unraid:** currently it sits in plain text in
`/root/.docker/config.json`. Später einen Credential-Helper einrichten, damit `/root/.docker/config.json`. Set up a credential helper later, so that the
die Klartext-Warnung von `docker login` verschwindet. plain-text warning from `docker login` disappears.
+239 -241
View File
@@ -1,160 +1,159 @@
# elternbeirat-igmh.de — Neuaufbau # elternbeirat-igmh.de — Rebuild
Ablösung der bisherigen WordPress-Seite durch eine eigene .NET-Anwendung auf Replacing the previous WordPress site with a custom .NET application on
eigener Infrastruktur. self-hosted infrastructure.
Stand: 2026-09-20 · Verantwortlich: Tom (Thomas Leininger) As of: 2026-09-20 · Responsible: Tom (Thomas Leininger)
--- ---
## 1. Ausgangslage ## 1. Starting Situation
- Die Domain `elternbeirat-igmh.de` lag bei Maik Palm (ausscheidendes - The domain `elternbeirat-igmh.de` was held by Maik Palm (departing
Elternbeirat-Mitglied) im Paket „STRATO Hosting Basic", Auftragsnummer 9157927. Elternbeirat member) under the "STRATO Hosting Basic" package, order number 9157927.
- Domaininhaber-Wechsel und Domainumzug sind beidseitig unterschrieben - The change of domain owner and the domain transfer are signed by both parties
(19.09.2026), die Einreichung bei Strato steht noch aus. (19.09.2026); the submission to Strato is still pending.
- Ziel-Paket: Toms „STRATO Mail Plus" (Auftragsnummer 8844576) — Domain + E-Mail, - Target package: Tom's "STRATO Mail Plus" (order number 8844576) — domain + email,
**kein Webspace**. **no web space**.
- **E-Mail bleibt bei Strato.** Nur die Website zieht auf eigene Hardware. - **Email stays with Strato.** Only the website moves to self-hosted hardware.
- **Die bisherige Website ist aktuell offline.** Der Inhaltsbestand ist damit der - **The previous website is currently offline.** The content is therefore the
zeitkritischste offene Punkt (→ Abschnitt 9). most time-critical open item (→ section 9).
--- ---
## 2. Ziele und Nicht-Ziele ## 2. Goals and Non-Goals
**Ziele** **Goals**
- Öffentliche Informationsseite des Elternbeirats: wer, wann, welche Protokolle, - A public information site for the Elternbeirat: who, when, which Protokolle,
wie erreichbar. how to reach us.
- Betrieb auf eigener Infrastruktur (Unraid), ohne fremden Hoster. - Operation on self-hosted infrastructure (Unraid), without a third-party hoster.
- Inhalte versionierbar und ohne Datenbank — ein `git clone` ist das vollständige - Content that is versionable and needs no database — a `git clone` is the complete
Backup. backup.
- Wartungsarm: keine Plugin-Updates, keine PHP-Sicherheitslücken, kein CMS-Login - Low maintenance: no plugin updates, no PHP security holes, no CMS login as an
als Angriffsfläche. attack surface.
**Nicht-Ziele (bewusst)** **Non-Goals (deliberate)**
- Kein CMS mit Web-Editor in Stufe 1. Falls andere Beiratsmitglieder später selbst - No CMS with a web editor in stage 1. If other Elternbeirat members should later
redaktionell arbeiten sollen, ist das ein eigener Ausbauschritt (→ Abschnitt 11). edit content themselves, that is a separate expansion step (→ section 11).
- Keine Benutzerkonten, kein Login, kein Mitgliederbereich. - No user accounts, no login, no members' area.
- Keine Datenbank. - No database.
- Keine externen Einbindungen (Fonts, Analytics, Maps, Social Widgets) — aus - No external integrations (fonts, analytics, maps, social widgets) — for
Datenschutzgründen, siehe Abschnitt 10. data-protection reasons, see section 10.
--- ---
## 3. Architekturentscheidungen ## 3. Architecture Decisions
### AE-1: Blazor mit Static Server-Side Rendering, nicht WebAssembly ### AE-1: Blazor with Static Server-Side Rendering, not WebAssembly
**Entscheidung:** Blazor Web App mit Interaktivitätsmodus *None* (reines statisches **Decision:** Blazor Web App with interactivity mode *None* (pure static SSR).
SSR). Zielframework .NET 10 (LTS). Target framework .NET 10 (LTS).
**Begründung:** Blazor WASM lädt mehrere MB Runtime vor dem ersten sichtbaren **Rationale:** Blazor WASM loads several MB of runtime before the first visible
Buchstaben, liefert Suchmaschinen und Link-Vorschauen (WhatsApp, Signal, Messenger letter, serves search engines and link previews (WhatsApp, Signal, Messenger —
— der Hauptverbreitungsweg bei Elternschaften) eine leere Shell und bringt auf the main distribution channel among parents) an empty shell, and provides no value
einer reinen Informationsseite keinerlei Gegenwert. Static SSR liefert fertiges whatsoever on a pure information site. Static SSR delivers finished HTML, needs no
HTML, braucht kein JavaScript und kostet im Container rund 60 MB RAM. JavaScript, and costs around 60 MB of RAM in the container.
**Konsequenz:** Interaktivität ist später pro Komponente nachrüstbar **Consequence:** Interactivity can be added later per component
(`@rendermode InteractiveServer` an genau der einen Komponente), ohne die (`@rendermode InteractiveServer` on exactly that one component), without changing
Architektur zu ändern. the architecture.
**Verworfene Alternativen:** **Rejected alternatives:**
| Alternative | Warum nicht | | Alternative | Why not |
|---|---| |---|---|
| Blazor WASM | Payload, SEO, Link-Vorschauen, kein Nutzen | | Blazor WASM | Payload, SEO, link previews, no benefit |
| ASP.NET Core MVC/Razor Pages | Funktioniert genauso, aber Razor Components sind das modernere Modell | | ASP.NET Core MVC/Razor Pages | Works just as well, but Razor Components are the more modern model |
| Statiq.Web (C#-SSG) → nginx | Ops-technisch am schlanksten (nichts zu patchen), aber jede Textänderung erzwingt einen Build-Lauf. Bleibt als Rückfallebene. | | Statiq.Web (C# SSG) → nginx | Leanest from an ops standpoint (nothing to patch), but every text change forces a build run. Kept as a fallback. |
| Astro/Hugo | Ausgereifteres SSG-Ökosystem, aber fremdes Terrain | | Astro/Hugo | More mature SSG ecosystem, but unfamiliar territory |
### AE-2: Inhalte als Dateien im Repo, nicht in einer Datenbank ### AE-2: Content as files in the repo, not in a database
**Entscheidung:** Seiteninhalte als Markdown mit YAML-Frontmatter, Termine als **Decision:** Page content as Markdown with YAML frontmatter, Termine as
strukturiertes YAML, Dokumente (Protokolle, Satzung) als PDF unter `wwwroot`. structured YAML, documents (Protokolle, bylaws) as PDF under `wwwroot`.
**Begründung:** Kein DB-Backup, kein Migrationsschema, keine Konsistenzprobleme **Rationale:** No DB backup, no migration schema, no consistency problems between
zwischen Dateien und Datenbank. Änderungen sind Commits und damit nachvollziehbar files and database. Changes are commits and are therefore traceable and
und rückrollbar. Für eine Seite mit ~10 Unterseiten und ein paar Terminen pro Jahr reversible. For a site with ~10 subpages and a few Termine per year, anything else
ist alles andere Overhead. is overhead.
**Konsequenz:** Textänderungen erfordern einen Commit und ein Redeploy. Das ist bei **Consequence:** Text changes require a commit and a redeploy. With an expected
erwarteten fünf Änderungen im Jahr akzeptabel — und der Grund, warum Abschnitt 11 five changes a year that is acceptable — and the reason why section 11 treats the
den Ausbau zum Web-Editor als eigene Stufe führt. expansion to a web editor as a separate stage.
### AE-3: Inhalte werden ins Image gebacken, nicht als Volume gemountet ### AE-3: Content is baked into the image, not mounted as a volume
**Entscheidung:** `Content/` und `wwwroot/` sind Teil des Images. **Decision:** `Content/` and `wwwroot/` are part of the image.
**Begründung:** Gemountete Inhalte aus dem Appdata-Share ließen sich zwar direkt **Rationale:** Mounted content from the appdata share could be edited directly on
am NAS editieren, aber genau dann driften Repo und Live-Stand auseinander — und the NAS, but that is exactly when the repo and the live state drift apart — and
die Eigenschaft „Backup = `git clone`" aus AE-2 wäre wertlos. the "backup = `git clone`" property from AE-2 would be worthless.
**Konsequenz:** Kein Schnell-Fix am Live-System. Tippfehler werden korrekt über **Consequence:** No quick fix on the live system. Typos are corrected properly via
einen Commit behoben. a commit.
### AE-4: TLS und Zertifikate ausschließlich im Nginx Proxy Manager ### AE-4: TLS and certificates exclusively in the Nginx Proxy Manager
**Entscheidung:** Der Container spricht nur HTTP auf Port 8080 und hat **kein** **Decision:** The container speaks only HTTP on port 8080 and has **no** port
Port-Mapping nach außen. NPM terminiert TLS und ist der einzige Weg zum Container. mapping to the outside. NPM terminates TLS and is the only path to the container.
**Begründung:** Entspricht dem bereits etablierten Muster im Home-Lab **Rationale:** Matches the pattern already established in the home lab
(`cloud.anticarnist.de`). Zertifikatsverwaltung bleibt an einer Stelle. (`cloud.anticarnist.de`). Certificate management stays in one place.
**Konsequenz (wichtig):** In `Program.cs` **kein** `UseHttpsRedirection()` und **Consequence (important):** In `Program.cs`, **no** `UseHttpsRedirection()` and
**kein** `UseHsts()` — sonst Redirect-Schleife hinter dem Proxy. HSTS setzt NPM. **no** `UseHsts()` — otherwise a redirect loop behind the proxy. NPM sets HSTS.
--- ---
## 4. Projekt anlegen (Rider) ## 4. Creating the Project (Rider)
Template **Blazor Web App** mit diesen Optionen: Template **Blazor Web App** with these options:
| Option | Wert | | Option | Value |
|---|---| |---|---|
| Framework | .NET 10.0 | | Framework | .NET 10.0 |
| Authentication | None | | Authentication | None |
| Interactive render mode | **None** | | Interactive render mode | **None** |
| Include sample pages | aus | | Include sample pages | off |
| Configure for HTTPS | an (nur für lokale Entwicklung relevant) | | Configure for HTTPS | on (only relevant for local development) |
| Do not use top-level statements | egal | | Do not use top-level statements | doesn't matter |
| Enlist in .NET Aspire orchestration | **aus** | | Enlist in .NET Aspire orchestration | **off** |
Projektname: `Elternbeirat.Web`, Solution: `Elternbeirat`. Project name: `Elternbeirat.Web`, Solution: `Elternbeirat`.
Wichtig ist allein *Interactive render mode = None* — damit erzeugt das Template The only thing that matters is *Interactive render mode = None* — this way the
kein `.Client`-Projekt und kein WebAssembly-Bundle. template creates no `.Client` project and no WebAssembly bundle.
**Angelegt und bestätigt (2026-09-20):** Blazor Web App, `net10.0`, Interactive **Created and confirmed (2026-09-20):** Blazor Web App, `net10.0`, Interactive
render mode `None`, Auth `None`, Sample pages aus, Docker-Optionen im Dialog aus render mode `None`, Auth `None`, sample pages off, Docker options in the dialog off
(Dockerfile schreiben wir selbst, siehe Abschnitt 7). Git-Repository beim (we write the Dockerfile ourselves, see section 7). Git repository created along
Anlegen mit erzeugt. Solution liegt direkt unter with it. The solution sits directly under `RiderProjects\Elternbeirat\`, the
`RiderProjects\Elternbeirat\`, das Projekt in `Elternbeirat.Web\` darunter — project in `Elternbeirat.Web\` below it — **no** intermediate `src/` directory,
**kein** `src/`-Zwischenverzeichnis, anders als ursprünglich in Abschnitt 5 unlike originally sketched in section 5.
skizziert.
NuGet-Pakete, die dazukommen: NuGet packages that get added:
- `Markdig` — Markdown-Rendering - `Markdig` — Markdown rendering
- `YamlDotNet` — Frontmatter und `termine.yml` - `YamlDotNet` — frontmatter and `termine.yml`
--- ---
## 5. Repo-Struktur ## 5. Repo Structure
``` ```
Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis Elternbeirat/ ← repo root, = solution directory
├── Elternbeirat.sln ├── Elternbeirat.sln
├── plan.md ← dieses Dokument ├── plan.md ← this document
├── CLAUDE.md ← minimal: Trigger, nicht-offensichtliche Kommandos ├── CLAUDE.md ← minimal: triggers, non-obvious commands
├── docs/ ├── docs/
│ ├── deployment.md ← Unraid, NPM, Registry, Rollback │ ├── deployment.md ← Unraid, NPM, registry, rollback
│ ├── dns.md ← Strato, DynDNS, Mail-Records │ ├── dns.md ← Strato, DynDNS, mail records
│ ├── inhalte-pflegen.md ← Anleitung für den Nicht-Alltagsfall │ ├── inhalte-pflegen.md ← guide for the not-everyday case
│ ├── inhalte-migration.md ← Übernahme aus dem alten WordPress │ ├── inhalte-migration.md ← import from the old WordPress
│ └── recht.md ← Impressum, Datenschutz, Fotos │ └── recht.md ← imprint, privacy, photos
├── Elternbeirat.Web/ ├── Elternbeirat.Web/
│ ├── Elternbeirat.Web.csproj │ ├── Elternbeirat.Web.csproj
│ ├── Components/ │ ├── Components/
@@ -164,7 +163,7 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
│ │ └── Pages/ Start, UeberUns, Termine, Protokolle, │ │ └── Pages/ Start, UeberUns, Termine, Protokolle,
│ │ News, NewsBeitrag, Kontakt, │ │ News, NewsBeitrag, Kontakt,
│ │ Impressum, Datenschutz, Fehler404 │ │ Impressum, Datenschutz, Fehler404
│ ├── Content/ ← Inhalte, kein Code │ ├── Content/ ← content, no code
│ │ ├── seiten/*.md │ │ ├── seiten/*.md
│ │ ├── news/2026-09-20-titel.md │ │ ├── news/2026-09-20-titel.md
│ │ └── termine.yml │ │ └── termine.yml
@@ -173,25 +172,25 @@ Elternbeirat/ ← Repo-Root, = Solution-Verzeichnis
│ │ ├── TermineService.cs │ │ ├── TermineService.cs
│ │ └── IcsWriter.cs │ │ └── IcsWriter.cs
│ ├── wwwroot/ │ ├── wwwroot/
│ │ ├── css/site.css ← eigenes CSS, keine CDN-Einbindung │ │ ├── css/site.css ← own CSS, no CDN integration
│ │ ├── img/ │ │ ├── img/
│ │ └── dokumente/ ← Protokolle, Satzung (PDF) │ │ └── dokumente/ ← Protokolle, bylaws (PDF)
│ └── Program.cs │ └── Program.cs
├── Elternbeirat.Web.Tests/ ← Smoke-Tests: jede Route liefert 200 ├── Elternbeirat.Web.Tests/ ← smoke tests: every route returns 200
├── Dockerfile ├── Dockerfile
├── compose.yaml ├── compose.yaml
├── .dockerignore ├── .dockerignore
└── .gitea/workflows/deploy.yml └── .gitea/workflows/deploy.yml
``` ```
`CLAUDE.md` bleibt bewusst kurz (Build-/Run-Kommandos, Stilregeln, Verweis auf `CLAUDE.md` deliberately stays short (build/run commands, style rules, pointer to
`docs/`). Die Details liegen in `docs/` und werden nur bei Bedarf gelesen. `docs/`). The details live in `docs/` and are read only when needed.
--- ---
## 6. Inhaltsmodell ## 6. Content Model
**Seite** (`Content/seiten/ueber-uns.md`): **Page** (`Content/seiten/ueber-uns.md`):
```markdown ```markdown
--- ---
@@ -203,7 +202,7 @@ beschreibung: Wer im Elternbeirat der IGMH mitarbeitet und wie wir arbeiten.
## Der Elternbeirat ## Der Elternbeirat
Fließtext … Body text …
``` ```
**Termin** (`Content/termine.yml`): **Termin** (`Content/termine.yml`):
@@ -217,69 +216,69 @@ Fließtext …
notiz: Gäste willkommen notiz: Gäste willkommen
``` ```
**News-Beitrag** (`Content/news/2026-09-20-neue-website.md`) — wie Seite, plus **News post** (`Content/news/2026-09-20-neue-website.md`) — like a page, plus
`datum` und `autor`. `datum` and `autor`.
Die Services lesen beim Start alles ein, cachen es im Speicher und stellen es The services read everything at startup, cache it in memory, and provide it in a
typisiert bereit. In Development zusätzlich ein `FileSystemWatcher`, damit typed form. In Development there is also a `FileSystemWatcher`, so that text
Textänderungen ohne Neustart sichtbar werden. changes become visible without a restart.
**Zusatznutzen ohne Mehraufwand:** ein ICS-Endpoint unter `/termine.ics`, der die **Added benefit at no extra cost:** an ICS endpoint at `/termine.ics` that serves
öffentlichen Termine ausliefert. Eltern abonnieren den Kalender einmal im Handy the public Termine. Parents subscribe to the calendar once on their phone and see
und sehen jede Sitzung automatisch. Das ist der eine Punkt, an dem die Eigenbau- every session automatically. This is the one point where the self-built solution
Lösung die alte WordPress-Seite spürbar schlägt. noticeably beats the old WordPress site.
--- ---
## 7. Build und Deployment ## 7. Build and Deployment
### Stufe 1 — Handbetrieb (Start hier) ### Stage 1 — Manual (start here)
```bash ```bash
docker compose build docker compose build
docker compose up -d docker compose up -d
``` ```
Für fünf Deployments im Jahr vollkommen ausreichend. CI vorab zu bauen wäre Entirely sufficient for five deployments a year. Building via CI in advance would
Selbstzweck. be an end in itself.
### Stufe 2 — Gitea Actions (Runner ist vorhanden) ### Stage 2 — Gitea Actions (runner is available)
`.gitea/workflows/deploy.yml` auf Push nach `main`: `.gitea/workflows/deploy.yml` on push to `main`:
1. `actions/checkout` 1. `actions/checkout`
2. Login an der Gitea-eigenen Container-Registry 2. Login to Gitea's own container registry
3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **und** `:${{ gitea.sha }}` 3. `docker/build-push-action` → `gitea.<domain>/tom/elternbeirat:latest` **and** `:${{ gitea.sha }}`
Zwei Stolpersteine, die erfahrungsgemäß Zeit kosten: Two pitfalls that experience shows cost time:
- Der `act_runner` im Docker-Modus braucht Zugriff auf einen Docker-Socket oder - The `act_runner` in Docker mode needs access to a Docker socket or a DinD
einen DinD-Service, sonst schlägt `build-push-action` fehl. service, otherwise `build-push-action` fails.
- Die Gitea-Registry braucht ein Paket-Token mit Schreibrecht (`write:package`), - The Gitea registry needs a package token with write permission (`write:package`),
nicht das normale Login-Passwort. not the normal login password.
**Immer auch den SHA-Tag pushen.** `:latest` allein macht Rollback unmöglich. **Always push the SHA tag too.** `:latest` alone makes rollback impossible.
### Redeploy auf Unraid ### Redeploy on Unraid
Watchtower, aber **label-scoped** — sonst aktualisiert er ungefragt den ganzen Watchtower, but **label-scoped** — otherwise it updates the entire home-lab
Home-Lab-Bestand: inventory unasked:
```yaml ```yaml
# im Watchtower-Container # in the Watchtower container
WATCHTOWER_LABEL_ENABLE: "true" WATCHTOWER_LABEL_ENABLE: "true"
``` ```
```yaml ```yaml
# im eb-web-Service # in the eb-web service
labels: labels:
com.centurylinklabs.watchtower.enable: "true" com.centurylinklabs.watchtower.enable: "true"
``` ```
Alternativ: manuell im Unraid-Docker-Tab „Update" drücken. Bei dieser Alternatively: press "Update" manually in the Unraid Docker tab. At this rate of
Änderungsfrequenz völlig legitim. change entirely legitimate.
### Dockerfile (Skizze) ### Dockerfile (sketch)
```dockerfile ```dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
@@ -296,14 +295,14 @@ EXPOSE 8080
ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"] ENTRYPOINT ["dotnet", "Elternbeirat.Web.dll"]
``` ```
Das chiseled-Image läuft ab .NET 8 standardmäßig als non-root (UID 1654) und The chiseled image runs as non-root (UID 1654) by default from .NET 8 on and
hört auf Port 8080. **Es enthält keine Shell** — ein `HEALTHCHECK` mit `curl` listens on port 8080. **It contains no shell** — a `HEALTHCHECK` with `curl` does
funktioniert dort nicht. Entweder das normale `aspnet:10.0-noble` verwenden oder not work there. Either use the normal `aspnet:10.0-noble` or leave monitoring to
die Überwachung NPM bzw. Uptime Kuma überlassen. NPM or Uptime Kuma.
--- ---
## 8. Hosting auf Unraid ## 8. Hosting on Unraid
```yaml ```yaml
# compose.yaml # compose.yaml
@@ -324,22 +323,22 @@ networks:
external: true external: true
``` ```
Kein `ports:`-Block. Der Container ist ausschließlich über das NPM-Docker-Netz No `ports:` block. The container is reachable exclusively via the NPM Docker
erreichbar. network.
**NPM Proxy Host:** **NPM Proxy Host:**
| Feld | Wert | | Field | Value |
|---|---| |---|---|
| Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` | | Domain Names | `elternbeirat-igmh.de`, `www.elternbeirat-igmh.de` |
| Scheme | `http` | | Scheme | `http` |
| Forward Hostname | `eb-web` | | Forward Hostname | `eb-web` |
| Forward Port | `8080` | | Forward Port | `8080` |
| Block Common Exploits | an | | Block Common Exploits | on |
| Websockets Support | aus (wird bei statischem SSR nicht gebraucht) | | Websockets Support | off (not needed with static SSR) |
| SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS | | SSL | Let's Encrypt, Force SSL, HTTP/2, HSTS |
**In `Program.cs` nicht vergessen:** **Don't forget in `Program.cs`:**
```csharp ```csharp
app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseForwardedHeaders(new ForwardedHeadersOptions
@@ -351,125 +350,124 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
}); });
``` ```
Ohne das sieht die App jede Anfrage als HTTP und mit der Proxy-IP statt der Without this, the app sees every request as HTTP and with the proxy IP instead of
Client-IP — relevant für korrekte absolute URLs und für die Logs. the client IP — relevant for correct absolute URLs and for the logs.
--- ---
## 9. Inhalte — zeitkritisch ## 9. Content — time-critical
Die alte Seite ist offline, Maiks Strato-Vertrag läuft aber noch. Solange er läuft, The old site is offline, but Maik's Strato contract is still running. As long as it
ist der Webspace erreichbar; nach der Kündigung ist der Bestand endgültig weg. runs, the web space is reachable; after cancellation the content is gone for good.
**Reihenfolge der Rettungsversuche:** **Order of rescue attempts:**
1. Prüfen, was beim Termin mit Maik tatsächlich gesichert wurde (Dateien? MySQL-Dump? 1. Check what was actually secured at the meeting with Maik (files? MySQL dump?
Beides?). Ein vollständiger Dump wäre der Idealfall — daraus lassen sich Texte, both?). A complete dump would be the ideal case — from it, texts, page
Seitenstruktur, Medien und PDFs sauber extrahieren. structure, media, and PDFs can be extracted cleanly.
2. Falls nur Dateien vorliegen: `wp-content/uploads` enthält Bilder und PDFs, die 2. If only files are available: `wp-content/uploads` contains images and PDFs, but
Texte liegen aber in der Datenbank. Dann Schritt 3. the texts live in the database. Then step 3.
3. Wayback Machine auf Snapshots von `elternbeirat-igmh.de` prüfen. 3. Check the Wayback Machine for snapshots of `elternbeirat-igmh.de`.
4. Falls nichts davon greift: Maik bitten, vor Vertragsende noch einen Export zu 4. If none of that works: ask Maik to pull an export before the contract ends — or
ziehen — oder Inhalte aus den Protokollen und von der Schulseite neu aufbauen. rebuild the content from the Protokolle and the school's website.
**Diese Frage blockiert den Inhaltsteil, nicht den Technikteil.** Das Gerüst lässt **This question blocks the content part, not the technical part.** The scaffolding
sich mit Platzhaltern bauen und später befüllen. can be built with placeholders and filled in later.
--- ---
## 10. Rechtliches ## 10. Legal
Kein Rechtsrat — aber die Punkte, an denen Schulseiten regelmäßig auffallen: Not legal advice — but the points where school sites regularly get flagged:
- **Impressum (§ 5 DDG):** Hat der Elternbeirat keine eigene Rechtsform, steht der - **Imprint (§ 5 DDG):** If the Elternbeirat has no legal form of its own, the
Betreiber persönlich mit Name und ladungsfähiger Anschrift im Impressum. Das ist operator is listed personally in the imprint with name and a valid postal
eine Entscheidung, keine Formalie — die Privatadresse wird damit öffentlich. address for service. This is a decision, not a formality — the private address
Alternative: Anschrift der Schule, aber nur mit deren ausdrücklichem Einverständnis becomes public. Alternative: the school's address, but only with its explicit
und wenn die Schule Mitbetreiberin ist. consent and if the school is a co-operator.
- **Datenschutzerklärung:** Mit dem Umzug ist Tom Verantwortlicher im Sinne der DSGVO. - **Privacy policy:** With the move, Tom becomes the controller in the sense of the
Server-Logs mit IP-Adressen benennen, Rechtsgrundlage und Löschfrist festlegen. GDPR. Name server logs with IP addresses, define the legal basis and the deletion
- **Keine externen Ressourcen.** Google Fonts, Maps, YouTube-Embeds und CDN-Skripte period.
übertragen die IP der Besucher an Dritte. Fonts werden selbst ausgeliefert. - **No external resources.** Google Fonts, Maps, YouTube embeds, and CDN scripts
- **Fotos von Kindern:** nur mit Einwilligung der Erziehungsberechtigten — bei transmit visitors' IPs to third parties. Fonts are served by ourselves.
Schulseiten der mit Abstand häufigste Fehler. Im Zweifel keine Personenfotos. - **Photos of children:** only with the consent of the legal guardians — by far the
- Hosting am privaten Anschluss bedeutet: Die öffentliche IP des Privatanschlusses most common mistake on school sites. When in doubt, no photos of people.
steht im DNS einer Schulseite. Bewusste Entscheidung, kein Nebeneffekt. - Hosting on a private connection means: the public IP of the private connection
appears in the DNS of a school site. A deliberate decision, not a side effect.
--- ---
## 11. Offene Punkte ## 11. Open Items
| # | Punkt | Status | | # | Item | Status |
|---|---|---| |---|---|---|
| 1 | Was wurde vom alten WordPress gesichert? | **offen, zeitkritisch** | | 1 | What was secured from the old WordPress? | **open, time-critical** |
| 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 | | 2 | Can "STRATO Mail Plus" do DynDNS? Per the Strato FAQ from "PowerWeb Basic 2013 or STRATO Domain" on — whether Mail Plus counts is unclear. Ask support along with the transfer while it is in progress. | open |
| 3 | DynDNS-Updater: Fritzbox (kennt als Exposed-Host-Vorschaltgerät die öffentliche IP) oder ddclient-Container auf Unraid? | offen | | 3 | DynDNS updater: Fritzbox (which, as an exposed-host upstream device, knows the public IP) or a ddclient container on Unraid? | open |
| 4 | Sollen andere Beiratsmitglieder Inhalte selbst pflegen können? Falls ja: eigener Ausbauschritt (Decap CMS auf Git-Basis oder kleines Admin-UI). | offen | | 4 | Should other Elternbeirat members be able to maintain content themselves? If yes: a separate expansion step (Decap CMS on a Git basis or a small admin UI). | open |
| 5 | Kontaktformular gewünscht? Würde Server-Interaktivität und Spam-Schutz erfordern — `mailto:` ist die aufwandsfreie Alternative. | offen | | 5 | Contact form wanted? Would require server interactivity and spam protection — `mailto:` is the effort-free alternative. | open |
| 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 | | 6 | Who steps in when Tom is unavailable? A site that only one person can deploy is a dependency the board should be aware of. | open |
| 7 | Formulare bei Strato einreichen (unterschrieben, liegt bereit) | offen | | 7 | Submit forms to Strato (signed, ready to go) | open |
--- ---
## 12. Umsetzungsreihenfolge ## 12. Implementation Order
| # | Schritt | Abhängig von | | # | Step | Depends on |
|---|---|---| |---|---|---|
| 1 | Strato-Formulare einreichen, Domainumzug anstoßen | ✅ beauftragt (2026-09-20) | | 1 | Submit Strato forms, start the domain transfer | ✅ commissioned (2026-09-20) |
| 2 | Inhaltslage klären (Abschnitt 9) | — | | 2 | Clarify the content situation (section 9) | — |
| 3 | Blazor-Projekt in Rider anlegen ✅ (2026-09-20) → nach Gitea pushen ✅ | — | | 3 | Create the Blazor project in Rider ✅ (2026-09-20) → push to Gitea ✅ | — |
| 6 | Dockerfile, compose.yaml → als Container auf Unraid ✅ (2026-09-20) | 3 | | 6 | Dockerfile, compose.yaml → as a container on Unraid ✅ (2026-09-20) | 3 |
| 4 | Content-Pipeline: Markdig, YamlDotNet, Services, ICS-Endpoint | 3 | | 4 | Content pipeline: Markdig, YamlDotNet, services, ICS endpoint | 3 |
| 5 | Layout, Navigation, Seiten mit Platzhaltern | 4 | | 5 | Layout, navigation, pages with placeholders | 4 |
| 7 | Echte Inhalte einpflegen | 2, 5 | | 7 | Add the real content | 2, 5 |
| 8 | Impressum und Datenschutzerklärung | 7 | | 8 | Imprint and privacy policy | 7 |
| 9 | DNS umstellen, NPM Proxy Host, Let's Encrypt | 1, 6 | | 9 | Switch DNS, NPM Proxy Host, Let's Encrypt | 1, 6 |
| 10 | Gitea Actions + Registry + Watchtower | 6, 9 | | 10 | Gitea Actions + registry + Watchtower | 6, 9 |
Schritte 1 und 2 laufen unabhängig vom Code und sollten sofort starten — Steps 1 and 2 run independently of the code and should start immediately —
Schritt 2 ist der einzige, bei dem Warten echten Schaden anrichtet. step 2 is the only one where waiting does real damage.
**Abweichung von der ursprünglichen Reihenfolge:** Schritt 6 (Deployment) wurde **Deviation from the original order:** Step 6 (deployment) was deliberately pulled
bewusst **vor** die Content-Pipeline (4/5) gezogen. Grund: die riskanteste Kette **ahead of** the content pipeline (4/5). Reason: prove the riskiest chain —
— Image bauen → Gitea-Registry → auf Unraid ziehen → Container läuft — früh build image → Gitea registry → pull on Unraid → container runs — early, rather
beweisen, statt sie erst kurz vor dem Livegang zu entdecken. Details in than discovering it just before go-live. Details in `docs/deployment.md`.
`docs/deployment.md`.
### Stand 2026-09-20 (abends) ### As of 2026-09-20 (evening)
Erreicht: Das rohe „Hello world" der Blazor-App läuft als Container auf Unraid Achieved: The raw "Hello world" of the Blazor app runs as a container on Unraid
(`Cube`), erreichbar im LAN unter `http://cube:5000`. Bewiesen ist damit die (`Cube`), reachable on the LAN at `http://cube:5000`. This proves the complete
komplette Deploy-Kette inkl. privater Gitea-Registry. deploy chain including the private Gitea registry.
Bewusste **Test-Abweichungen** vom Produktivziel (Abschnitt 8), später Deliberate **test deviations** from the production target (section 8), to be
zurückzubauen: rolled back later:
- `compose.yaml` hat ein Port-Mapping `5000:8080`. Produktiv: kein Mapping, nur - `compose.yaml` has a port mapping `5000:8080`. In production: no mapping, only
über das externe `npm`-Netz (NPM ist noch nicht testbar, Domain zieht erst um). via the external `npm` network (NPM is not testable yet, the domain moves first).
- Image-Tag nur `:latest`, noch kein SHA-Tag (→ Schritt 10, Rollback). - Image tag only `:latest`, no SHA tag yet (→ step 10, rollback).
- Registry-Token liegt auf Unraid im Klartext (`/root/.docker/config.json`). - The registry token sits on Unraid in plain text (`/root/.docker/config.json`).
Credential-Helper ist als späterer Punkt in `docs/deployment.md` notiert. A credential helper is noted as a later item in `docs/deployment.md`.
**Nächster Schritt:** Content-Pipeline (Schritt 4) — Markdig + YamlDotNet, **Next step:** Content pipeline (step 4) — Markdig + YamlDotNet, services for
Services für Seiten/Termine, ICS-Endpoint. Parallel offen und unabhängig vom pages/Termine, ICS endpoint. Open in parallel and independent of the code:
Code: Inhaltslage klären (Schritt 2, zeitkritisch). clarify the content situation (step 2, time-critical).
### Stand 2026-09-21 (vormittags) ### As of 2026-09-21 (morning)
Deployment weiter ausgebaut und einmal komplett durchgespielt: Deployment further built out and run through completely once:
- **Branch/PR-Workflow** etabliert: nie direkt auf `main`; Feature-Branch → Pull - **Branch/PR workflow** established: never directly on `main`; feature branch →
Request in Gitea → Merge. Erstmals durchgeführt (PR #1). pull request in Gitea → merge. Carried out for the first time (PR #1).
- **Zwei Build-Skripte** in `scripts/`: `dev-build.sh` (lokales Dev-Image, kein - **Two build scripts** in `scripts/`: `dev-build.sh` (local dev image, no push —
Push — Dev-Stände bleiben aus der Registry raus) und `release.sh` (baut aus dev states stay out of the registry) and `release.sh` (builds from `main`, tags
`main`, taggt `:latest` **und** Commit-Kurz-SHA, pusht beide; bricht ab, wenn `:latest` **and** the short commit SHA, pushes both; aborts if not on `main` or
nicht auf `main` oder Arbeitsverzeichnis unsauber). the working directory is dirty).
- **SHA-Tagging** ist damit Standard → Rollback möglich. Erster Release-Tag: - **SHA tagging** is now standard → rollback possible. First release tag:
`:8643f2c`. Redeploy auf Unraid (Compose Down/Up) bewusst geübt, läuft. `:8643f2c`. Redeploy on Unraid (Compose Down/Up) deliberately practiced, works.
- `.gitattributes` erzwingt LF für `*.sh` (sonst scheitert der Shebang unter - `.gitattributes` enforces LF for `*.sh` (otherwise the shebang fails on Windows).
Windows).
Offen fürs nächste Mal (unverändert): Content-Pipeline (Schritt 4) und die Open for next time (unchanged): content pipeline (step 4) and the time-critical
zeitkritische Inhaltslage (Schritt 2). Deployment-Automatisierung per Gitea content situation (step 2). Deployment automation via Gitea Actions (step 10) is
Actions (Schritt 10) ist der nächste optionale Deployment-Ausbau, aber nicht the next optional deployment expansion, but not urgent — manual operation via
dringend — der Handbetrieb über `release.sh` reicht. `release.sh` is enough.
+7 -7
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Baut ein lokales Dev-Image zum Ausprobieren auf dem eigenen Rechner. # Builds a local dev image for trying things out on your own machine.
# Pusht NICHTS in die Registry -- der Stand bleibt privat auf dem Laptop. # Pushes NOTHING to the registry -- the build stays private on the laptop.
# #
# Der Tag enthaelt den aktuellen Branchnamen, damit Dev-Images nicht mit dem # The tag carries the current branch name so dev images are not confused with
# Produktiv-:latest verwechselt werden. Anschliessend z.B. lokal starten: # the production :latest. Afterwards start it locally, e.g.:
# docker run --rm -p 5000:8080 elternbeirat-web:dev-<branch> # docker run --rm -p 5000:8080 elternbeirat-web:dev-<branch>
# oder ueber die lokale compose.yaml. # or via the local compose.yaml.
set -euo pipefail set -euo pipefail
@@ -14,9 +14,9 @@ cd "$(dirname "$0")/.."
branch="$(git rev-parse --abbrev-ref HEAD | tr '/' '-')" branch="$(git rev-parse --abbrev-ref HEAD | tr '/' '-')"
tag="elternbeirat-web:dev-$branch" tag="elternbeirat-web:dev-$branch"
echo "Baue lokales Dev-Image: $tag (kein Push)" echo "Building local dev image: $tag (no push)"
docker build -t "$tag" . docker build -t "$tag" .
echo echo
echo "Fertig. Lokal starten z.B. mit:" echo "Done. Start it locally, e.g. with:"
echo " docker run --rm -p 5000:8080 $tag" echo " docker run --rm -p 5000:8080 $tag"
+22 -22
View File
@@ -1,55 +1,55 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Baut das Produktiv-Image aus dem aktuellen main-Stand und pusht es in die # Builds the production image from the current main state and pushes it to the
# Gitea-Registry, getaggt mit :latest UND dem Commit-Kurz-SHA (Rollback). # Gitea registry, tagged with :latest AND the short commit SHA (rollback).
# #
# Nur fuer freigegebene Staende: das Skript verweigert den Push, wenn du nicht # For approved states only: the script refuses to push if you are not on main
# auf main bist oder uncommittete Aenderungen hast. Fuer Dev-Builds ohne Push # or have uncommitted changes. For dev builds without a push use
# stattdessen scripts/dev-build.sh nutzen. # scripts/dev-build.sh instead.
# #
# Voraussetzung: einmalig `docker login gitea.anticarnist.de` (siehe # Prerequisite: a one-time `docker login gitea.anticarnist.de` (see
# docs/deployment.md). # docs/deployment.md).
set -euo pipefail set -euo pipefail
IMAGE="gitea.anticarnist.de/tom/elternbeirat" IMAGE="gitea.anticarnist.de/tom/elternbeirat"
# Ins Repo-Root wechseln (Skript liegt in scripts/), damit der Docker-Build- # Change into the repo root (the script lives in scripts/) so the Docker build
# Kontext stimmt, egal von wo aufgerufen. # context is correct no matter where it is called from.
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
branch="$(git rev-parse --abbrev-ref HEAD)" branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$branch" != "main" ]]; then if [[ "$branch" != "main" ]]; then
echo "ABBRUCH: du bist auf '$branch', nicht auf 'main'." >&2 echo "ABORT: you are on '$branch', not on 'main'." >&2
echo "Ein :latest-Release darf nur aus main gebaut werden." >&2 echo "A :latest release may only be built from main." >&2
echo "Fuer einen Dev-Build ohne Push: scripts/dev-build.sh" >&2 echo "For a dev build without a push: scripts/dev-build.sh" >&2
exit 1 exit 1
fi fi
if [[ -n "$(git status --porcelain)" ]]; then if [[ -n "$(git status --porcelain)" ]]; then
echo "ABBRUCH: Arbeitsverzeichnis nicht sauber (uncommittete Aenderungen)." >&2 echo "ABORT: working tree is not clean (uncommitted changes)." >&2
echo "Erst committen, damit der SHA-Tag den Image-Inhalt eindeutig benennt." >&2 echo "Commit first so the SHA tag names the image content unambiguously." >&2
exit 1 exit 1
fi fi
# Warnung, wenn lokaler main dem Remote voraus ist (ungepushte Commits) — dann # Warn if the local main is ahead of the remote (unpushed commits) -- otherwise
# wuerde ein SHA getaggt, den es auf Gitea noch nicht gibt. # a SHA would be tagged that does not yet exist on Gitea.
if git rev-parse --verify --quiet origin/main >/dev/null; then if git rev-parse --verify --quiet origin/main >/dev/null; then
ahead="$(git rev-list --count origin/main..HEAD)" ahead="$(git rev-list --count origin/main..HEAD)"
if [[ "$ahead" -gt 0 ]]; then if [[ "$ahead" -gt 0 ]]; then
echo "WARNUNG: lokaler main ist origin/main um $ahead Commit(s) voraus." >&2 echo "WARNING: local main is ahead of origin/main by $ahead commit(s)." >&2
echo " Erst 'git push', damit der SHA auf Gitea existiert." >&2 echo " Run 'git push' first so the SHA exists on Gitea." >&2
read -r -p "Trotzdem fortfahren? [y/N] " answer read -r -p "Continue anyway? [y/N] " answer
[[ "$answer" == "y" || "$answer" == "Y" ]] || exit 1 [[ "$answer" == "y" || "$answer" == "Y" ]] || exit 1
fi fi
fi fi
sha="$(git rev-parse --short HEAD)" sha="$(git rev-parse --short HEAD)"
echo "Baue $IMAGE (Tags: latest, $sha)" echo "Building $IMAGE (tags: latest, $sha)"
docker build -t "$IMAGE:latest" -t "$IMAGE:$sha" . docker build -t "$IMAGE:latest" -t "$IMAGE:$sha" .
docker push "$IMAGE" --all-tags docker push "$IMAGE" --all-tags
echo echo
echo "Fertig. Gepusht: $IMAGE:latest und $IMAGE:$sha" echo "Done. Pushed: $IMAGE:latest and $IMAGE:$sha"
echo "Auf Unraid: Stack 'elternbeirat' -> Compose Down/Up (bzw. Pull), damit" echo "On Unraid: stack 'elternbeirat' -> Compose Down/Up (or Pull) so the new"
echo "das neue :latest gezogen wird." echo ":latest is fetched."