Merge pull request 'Build the content pipeline: pages, posts, events, layout' (#2) from feature/content-pipeline into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
Tom committed 2026-09-21 16:33:57 +02:00
commit faf32a4eb1
44 files changed
+1907 -391

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ obj/
riderModule.iml riderModule.iml
/_ReSharper.Caches/ /_ReSharper.Caches/
.idea/ .idea/
# Altbestand der WordPress-Seite (Sichtung/Migration, kann DB-Dumps mit
# personenbezogenen Daten und grosse Binaerdateien enthalten) -- nie ins Repo.
/backup/
+11 -6
View File
@@ -38,8 +38,9 @@ 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 Beitrag = Markdown in
`Content/termine.yml`. In beiden Fällen wird **kein** `.razor` angefasst. `Content/posts/`. Neuer Termin = Eintrag in `Content/events.yml`. In allen
Fällen wird **kein** `.razor` angefasst.
- Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`). - Slugs ohne Umlaute (`ueber-uns`, nicht `über-uns`).
## Regeln ## Regeln
@@ -60,7 +61,11 @@ 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 durchgängig auf Englisch** — Typen, Member, Variablen, Kommentare,
(`Termin`, `Protokoll`, `Beitrag`) — Framework-Begriffe bleiben englisch. Skripte und Doku. Das gilt **auch für Domänenbegriffe**: im Code `Event`, nicht
`Termin`; `Post`, nicht `Beitrag`. Deutsch bleibt ausschließlich, was ein
Besucher liest oder ein Redakteur pflegt: UI-Texte, Markdown-Inhalte,
Frontmatter-Schlüssel wie `titel:` sowie Slugs/Dateinamen unter `Content/`
(z. B. `vorstandsteam`, `/termine`).
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elternbeirat.Web\Elternbeirat.Web.csproj" />
</ItemGroup>
</Project>
+67
View File
@@ -0,0 +1,67 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Smoke tests: every known route must return 200, unknown routes must return
/// 404. This catches broken content files, renamed slugs or routing regressions
/// before a deploy.
/// </summary>
public sealed class RouteSmokeTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public RouteSmokeTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
/// <summary>
/// Every route that a visitor can reach through the navigation, the FAQ hub
/// or the news section.
/// </summary>
public static TheoryData<string> KnownRoutes =>
[
"/",
"/vorstandsteam",
"/foerderverein",
"/faq",
"/faq-mensa",
"/faq-schliessfach",
"/faq-elterneuro",
"/faq-elternarbeit",
"/downloads",
"/kontakt",
"/impressum",
"/datenschutz",
"/beitraege",
"/beitraege/neuer-vorstand-gewaehlt",
"/beitraege/neue-sporthalle-eroeffnet",
"/termine",
"/termine.ics",
];
[Theory]
[MemberData(nameof(KnownRoutes))]
public async Task Known_route_returns_200(string route)
{
var client = _factory.CreateClient();
var response = await client.GetAsync(route);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("/gibt-es-nicht")]
[InlineData("/beitraege/gibt-es-nicht")]
public async Task Unknown_route_returns_404(string route)
{
var client = _factory.CreateClient();
var response = await client.GetAsync(route);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
@@ -1,3 +1,34 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="page">
<header class="site-header">
<div class="header-inner">
<a class="brand" href="/">Elternbeirat IGMH</a>
<nav class="main-nav">
<NavLink href="/" Match="NavLinkMatch.All">Start</NavLink>
<NavLink href="/vorstandsteam">Vorstandsteam</NavLink>
<NavLink href="/foerderverein">Förderverein</NavLink>
<NavLink href="/beitraege">Beiträge</NavLink>
<NavLink href="/termine">Termine</NavLink>
<NavLink href="/faq">FAQ</NavLink>
<NavLink href="/downloads">Downloads</NavLink>
<NavLink href="/kontakt">Kontakt</NavLink>
</nav>
</div>
</header>
<main class="content">
@Body @Body
</main>
<footer class="site-footer">
<div class="footer-inner">
<span>Elternbeirat der IGMH</span>
<nav class="footer-nav">
<a href="/kontakt">Kontakt</a>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</nav>
</div>
</footer>
</div>
@@ -18,3 +18,100 @@
right: 0.75rem; right: 0.75rem;
top: 0.5rem; top: 0.5rem;
} }
/* Page frame: header and footer stay put, the content grows and pushes the
footer to the bottom even on short pages. */
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.site-header {
border-bottom: 1px solid #e0e0e0;
background: #fff;
}
.header-inner {
max-width: 60rem;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1.5rem;
}
.brand {
font-size: 1.35rem;
font-weight: 700;
color: #1f3a5f;
text-decoration: none;
white-space: nowrap;
}
.main-nav {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
}
.main-nav a {
color: #333;
text-decoration: none;
padding: 0.2rem 0;
border-bottom: 2px solid transparent;
}
.main-nav a:hover {
color: #1f3a5f;
}
/* NavLink adds .active to the link of the current route. */
.main-nav a.active {
color: #1f3a5f;
border-bottom-color: #1f3a5f;
}
/* Content */
.content {
flex: 1;
width: 100%;
max-width: 60rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
/* Footer */
.site-footer {
border-top: 1px solid #e0e0e0;
background: #fafafa;
color: #555;
font-size: 0.9rem;
}
.footer-inner {
max-width: 60rem;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem 1.5rem;
}
.footer-nav {
display: flex;
gap: 1.25rem;
}
.footer-nav a {
color: #555;
text-decoration: none;
}
.footer-nav a:hover {
color: #1f3a5f;
text-decoration: underline;
}
@@ -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; }
}
@@ -0,0 +1,91 @@
@page "/termine"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject EventService EventService
<PageTitle>Termine</PageTitle>
<h1>Termine</h1>
<p class="intro">
Alle Termine des Elternbeirats auf einen Blick. Sie können den Kalender auch
<a href="/termine.ics">abonnieren</a> und in Ihrer Kalender-App automatisch
aktuell halten.
</p>
<h2>Kommende Termine</h2>
@if (EventService.Upcoming.Count == 0)
{
<p>Zurzeit sind keine Termine geplant.</p>
}
else
{
<ul class="event-list">
@foreach (var ev in EventService.Upcoming)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
@if (!string.IsNullOrWhiteSpace(ev.Note))
{
<p class="event-note">@ev.Note</p>
}
</li>
}
</ul>
}
@if (EventService.Past.Count > 0)
{
<h2>Vergangene Termine</h2>
<ul class="event-list event-list-past">
@foreach (var ev in EventService.Past)
{
<li>
<time datetime="@ev.Start.ToString("s")">@Format(ev)</time>
<span class="event-title">@ev.Title</span>
@if (!string.IsNullOrWhiteSpace(ev.Location))
{
<span class="event-location">@ev.Location</span>
}
</li>
}
</ul>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
// Formats the entry's date range for display: a single day, a date with a
// time, or a span across days.
private static string Format(Event ev)
{
var start = ev.HasTime
? ev.Start.ToString("dddd, d. MMMM yyyy, HH:mm", _german) + " Uhr"
: ev.Start.ToString("dddd, d. MMMM yyyy", _german);
if (ev.End is not { } end)
{
return start;
}
// Same day: append just the end time. Different days: append the full
// end date.
if (end.Date == ev.Start.Date)
{
return ev.HasTime
? start + "–" + end.ToString("HH:mm", _german) + " Uhr"
: start;
}
var endText = ev.HasTime
? end.ToString("d. MMMM yyyy, HH:mm", _german) + " Uhr"
: end.ToString("d. MMMM yyyy", _german);
return start + " – " + endText;
}
}
+46 -4
View File
@@ -1,7 +1,49 @@
@page "/" @page "/"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
<PageTitle>Home</PageTitle> <PageTitle>Elternbeirat der IGMH</PageTitle>
<h1>Hello, world!</h1> <h1>Elternbeirat der IGMH</h1>
Welcome to your new app. <p class="intro">
Willkommen beim Elternbeirat der IGMH. Hier finden Sie aktuelle Informationen,
Antworten auf häufige Fragen und Unterlagen rund um die Elternarbeit.
</p>
<section class="home-news">
<h2>Aktuelle Beiträge</h2>
@if (_latest.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in _latest)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", _german)
</time>
</li>
}
</ul>
<p><a href="/beitraege">Alle Beiträge &rarr;</a></p>
}
</section>
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
private IReadOnlyList<Post> _latest = [];
protected override void OnInitialized()
{
// Show the three most recent posts as a teaser on the home page.
_latest = PostService.All.Take(3).ToList();
}
}
@@ -0,0 +1,46 @@
@page "/beitraege/{Slug}"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
@if (_post is null)
{
<PageTitle>Nicht gefunden</PageTitle>
}
else
{
<PageTitle>@_post.Title</PageTitle>
<article>
<p class="post-meta">
<a href="/beitraege">&larr; Alle Beiträge</a>
<time datetime="@_post.Date.ToString("yyyy-MM-dd")">
@_post.Date.ToString("d. MMMM yyyy", _german)
</time>
</p>
@((MarkupString)_post.ContentHtml)
</article>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
[Parameter]
public string Slug { get; set; } = "";
private Post? _post;
protected override void OnParametersSet()
{
_post = PostService.Find(Slug);
// Unknown slug -> 404, so UseStatusCodePagesWithReExecute serves the
// /not-found page instead of an empty 200 response.
if (_post is null && HttpContext is not null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
}
@@ -0,0 +1,31 @@
@page "/beitraege"
@using System.Globalization
@using Elternbeirat.Web.Services
@inject PostService PostService
<PageTitle>Beiträge</PageTitle>
<h1>Beiträge</h1>
@if (PostService.All.Count == 0)
{
<p>Zurzeit gibt es keine Beiträge.</p>
}
else
{
<ul class="post-list">
@foreach (var post in PostService.All)
{
<li>
<a href="@($"/beitraege/{post.Slug}")">@post.Title</a>
<time datetime="@post.Date.ToString("yyyy-MM-dd")">
@post.Date.ToString("d. MMMM yyyy", _german)
</time>
</li>
}
</ul>
}
@code {
private static readonly CultureInfo _german = CultureInfo.GetCultureInfo("de-DE");
}
+26
View File
@@ -0,0 +1,26 @@
# Calendar entries of the Elternbeirat.
#
# One entry per event. Required keys: title, start.
# start/end:
# date only -> "2026-03-15" (all-day)
# date with time -> "2026-03-15 19:30"
# location and note are optional.
- title: Elternbeiratssitzung
start: "2026-10-08 19:30"
location: Lehrerzimmer
note: Themen bitte vorab per E-Mail einreichen.
- title: Herbstbasar
start: "2026-11-14 10:00"
end: "2026-11-14 16:00"
location: Aula
- title: Weihnachtsferien
start: "2026-12-21"
end: "2027-01-06"
- title: Elternsprechtag
start: "2026-09-05 15:00"
end: "2026-09-05 18:00"
location: Klassenräume
@@ -0,0 +1,22 @@
---
title: Datenschutz
---
# Datenschutzerklärung
*TODO: Rechtlich verbindliche Datenschutzerklärung ergänzen. Erst nach Freigabe
finalisieren – siehe `docs/recht.md` (noch anzulegen).*
Diese Website wird bewusst ohne externe Ressourcen betrieben: keine CDN-Skripte,
keine Google Fonts, keine Karten- oder Video-Einbettungen. Schriften werden von
unserem eigenen Server ausgeliefert. Dadurch werden beim Besuch keine Daten an
Dritte übertragen.
## Verantwortliche Stelle
*TODO: siehe [Impressum](/impressum).*
## Server-Logdaten
*TODO: beschreiben, welche Zugriffsdaten der Server (bzw. der vorgelagerte
Proxy) protokolliert und wie lange.*
@@ -0,0 +1,17 @@
---
title: Downloads
---
# Downloads
Hier stellen wir Unterlagen zur Elternarbeit als Datei bereit: Protokolle der
Sitzungen, Elterninformationen und die Geschäftsordnung.
## Unterlagen
*TODO: Download-Liste ergänzen. Dateien liegen unter `wwwroot/downloads/` und
werden hier verlinkt, z. B.:*
- *Protokoll der Vollversammlung (PDF)*
- *Geschäftsordnung des Elternbeirats (PDF)*
- *Elterninformation Elternvertreter (PDF)*
@@ -0,0 +1,20 @@
---
title: FAQ Elternarbeit
---
# Elternarbeit
Unterstützung für neu gewählte und erfahrene Elternvertreterinnen und
Elternvertreter.
## Welche Aufgaben habe ich als Elternvertreter?
*TODO: Aufgaben und Rechte der Klassenelternvertretung beschreiben.*
## Wie läuft die Zusammenarbeit mit dem Elternbeirat?
*TODO: Sitzungsrhythmus und Ansprechpartner ergänzen.*
## Wo finde ich Vorlagen und Unterlagen?
Unterlagen und Protokolle finden Sie im Bereich [Downloads](/downloads).
@@ -0,0 +1,22 @@
---
title: FAQ Elterneuro
---
# Elterneuro
Der Elterneuro ist ein freiwilliger Beitrag der Eltern, mit dem der Elternbeirat
Projekte an der Schule unterstützt.
## Wofür wird der Elterneuro verwendet?
*TODO: konkrete Beispiele für die Verwendung ergänzen.*
## Wie hoch ist der Beitrag?
Der Elterneuro ist freiwillig.
*TODO: übliche Beitragshöhe und Zahlungsweg ergänzen.*
## An wen kann ich mich bei Fragen wenden?
Bei Fragen erreichen Sie uns über die [Kontaktseite](/kontakt).
@@ -0,0 +1,21 @@
---
title: FAQ Mensa
---
# Mensa
Alles rund um das Mittagessen an der IGMH: Anmeldung, Guthaben und Fristen.
## Wie melde ich mein Kind an?
Die Essensbestellung läuft über das System i-NET Menue.
*TODO: Ablauf der Erstanmeldung und Zugangsdaten beschreiben.*
## Wie lade ich Guthaben auf?
*TODO: Aufladeweg und Zahlungsarten ergänzen.*
## Bis wann kann ich bestellen oder stornieren?
*TODO: Fristen für Bestellung und Stornierung ergänzen.*
@@ -0,0 +1,21 @@
---
title: FAQ Schließfach
---
# Schließfächer
Informationen zur Anmietung eines Schließfachs an der IGMH.
## Welche Größen gibt es und was kosten sie?
*TODO: Verfügbare Größen und Preise ergänzen.*
## Wie miete ich ein Schließfach an?
Die Verwaltung läuft über das Serviceportal von AstraDirect.
*TODO: Ablauf der Anmietung und Link zum Portal ergänzen.*
## Wie tausche oder kündige ich mein Fach?
*TODO: Vorgehen für Fachtausch und Kündigung ergänzen.*
+15
View File
@@ -0,0 +1,15 @@
---
title: FAQ
---
# Häufige Fragen
Hier finden Eltern Antworten auf wiederkehrende Fragen rund um den Schulalltag.
Die Themen sind nach Bereichen aufgeteilt:
- [Mensa](/faq-mensa) – Anmeldung, Guthaben, Fristen
- [Schließfächer](/faq-schliessfach) – Größen, Preise, Verwaltung
- [Elterneuro](/faq-elterneuro) – freiwilliger Beitrag und Verwendung
- [Elternarbeit](/faq-elternarbeit) – Tipps für Elternvertreterinnen und Elternvertreter
*TODO: Reihenfolge und weitere Themen ergänzen, sobald die Unterseiten stehen.*
@@ -0,0 +1,19 @@
---
title: Förderverein
---
# Förderverein „Freunde der IGMH"
Der Förderverein „Freunde der IGMH" unterstützt die Schule bei Anschaffungen und
Projekten, die aus dem regulären Budget nicht finanziert werden können. Mitglieder
sind Eltern, Lehrkräfte, Ehemalige und Förderer der Schule.
## Was der Verein fördert
- Ausstattung für Unterricht und Arbeitsgemeinschaften
- Musische, sportliche und kulturelle Projekte
- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen
## Mitglied werden
*TODO: Beitrittsformular bzw. Ansprechpartner und Beitragshöhe ergänzen.*
@@ -0,0 +1,24 @@
---
title: Impressum
---
# Impressum
*TODO: Rechtlich verbindliches Impressum ergänzen. Erst nach Freigabe mit echten
Daten füllen – siehe `docs/recht.md` (noch anzulegen).*
## Angaben gemäß § 5 DDG
*TODO: Name und Anschrift des Diensteanbieters (Elternbeirat / Schule).*
## Vertreten durch
*TODO: gesetzlicher Vertreter.*
## Kontakt
*TODO: E-Mail-Adresse (siehe [Kontakt](/kontakt)).*
## Verantwortlich i. S. d. § 18 Abs. 2 MStV
*TODO: Name und Anschrift der verantwortlichen Person.*
+16
View File
@@ -0,0 +1,16 @@
---
title: Kontakt
---
# Kontakt
Sie erreichen den Elternbeirat der IGMH per E-Mail:
[TODO-adresse@example.org](mailto:TODO-adresse@example.org)
*TODO: Echte Kontakt-E-Mail-Adresse eintragen.*
## Anschrift
Elternbeirat der IGMH
*TODO: Anschrift der Schule ergänzen.*
@@ -0,0 +1,18 @@
---
title: Vorstandsteam
---
# Das Vorstandsteam
Der Elternbeirat der IGMH wird von einem ehrenamtlichen Vorstand geleitet.
Er vertritt die Elternschaft gegenüber Schule und Schulträger und koordiniert
die Arbeit der Klassenelternvertreter.
## Aufgaben des Vorstands
- Vertretung der Eltern in der Schulkonferenz
- Zusammenarbeit mit Schulleitung und Kollegium
- Organisation der Vollversammlungen und Sitzungen
- Ansprechpartner für Fragen rund um Mensa, Schließfächer und Förderverein
*Die namentliche Vorstellung der Vorstandsmitglieder folgt.*
@@ -0,0 +1,11 @@
---
title: Neue Sporthalle feierlich eröffnet
date: 2025-10-05
---
# Neue Sporthalle feierlich eröffnet
Die neue Sporthalle der IGMH ist eröffnet. Schülerinnen, Schüler und Lehrkräfte
haben damit deutlich mehr Platz für Sportunterricht und Arbeitsgemeinschaften.
*TODO: Bericht und Fotos ergänzen.*
@@ -0,0 +1,12 @@
---
title: Neuer Vorstand des Elternbeirats gewählt
date: 2025-02-26
---
# Neuer Vorstand des Elternbeirats gewählt
Bei der Vollversammlung hat der Elternbeirat der IGMH einen neuen Vorstand
gewählt. Das Team bedankt sich für das entgegengebrachte Vertrauen und freut
sich auf die gemeinsame Arbeit im neuen Schuljahr.
*TODO: Namen und Ämter des neuen Vorstands ergänzen (nach Freigabe).*
+17
View File
@@ -7,4 +7,21 @@
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException> <BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup> </PropertyGroup>
<!-- The test project uses WebApplicationFactory<Program>, which needs the
auto-generated Program class (internal with top-level statements). -->
<ItemGroup>
<InternalsVisibleTo Include="Elternbeirat.Web.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup>
<!-- Content ships as files inside the image, not in a volume. Copy it to
the output directory so the container can find it. -->
<ItemGroup>
<Content Include="Content\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>
+18 -8
View File
@@ -1,4 +1,5 @@
using Elternbeirat.Web.Components; using Elternbeirat.Web.Components;
using Elternbeirat.Web.Services;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -6,13 +7,18 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
// Content is read once at startup and cached -> singletons.
builder.Services.AddSingleton<PageService>();
builder.Services.AddSingleton<PostService>();
builder.Services.AddSingleton<EventService>();
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,
@@ -24,9 +30,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);
@@ -36,4 +41,9 @@ app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// Subscribable calendar feed of all events. A minimal API endpoint rather than
// a Razor page because it returns text/calendar, not HTML.
app.MapGet("/termine.ics", (EventService events) =>
Results.Text(IcsCalendar.Build(events.All), "text/calendar; charset=utf-8"));
app.Run(); app.Run();
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A single calendar entry (parents' evening, meeting, deadline). Read from
/// <c>Content/events.yml</c> at startup. All display text is German because it
/// is visitor-facing content.
/// </summary>
public sealed class Event
{
/// <summary>Short title shown in the list and the calendar.</summary>
public required string Title { get; init; }
/// <summary>
/// When the entry starts. A date-only entry (no time) is treated as an
/// all-day event; see <see cref="HasTime"/>.
/// </summary>
public required DateTime Start { get; init; }
/// <summary>
/// True when <see cref="Start"/> carries a wall-clock time, false for an
/// all-day entry. Drives both the list formatting and the ICS export.
/// </summary>
public required bool HasTime { get; init; }
/// <summary>Optional end, for multi-hour or multi-day entries.</summary>
public DateTime? End { get; init; }
/// <summary>Optional location, e.g. "Aula" or "Mensa".</summary>
public string? Location { get; init; }
/// <summary>Optional free-text note shown below the entry.</summary>
public string? Note { get; init; }
}
+137
View File
@@ -0,0 +1,137 @@
using System.Globalization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the calendar entries from <c>Content/events.yml</c> 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 EventService
{
private readonly IReadOnlyList<Event> _events;
/// <summary>
/// Reads and parses <c>Content/events.yml</c> below the application root.
/// A missing file yields an empty list rather than an error.
/// </summary>
public EventService(IWebHostEnvironment environment)
{
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
_events = File.Exists(path)
? Parse(File.ReadAllText(path))
: [];
}
/// <summary>
/// Entries that start today or later, earliest first — the upcoming section.
/// "Today" is compared by date, so an entry earlier today still counts as
/// upcoming.
/// </summary>
public IReadOnlyList<Event> Upcoming =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
.OrderBy(e => e.Start)
.ToList();
/// <summary>
/// Entries that already passed, most recent first — the past section shown
/// below the upcoming ones.
/// </summary>
public IReadOnlyList<Event> Past =>
_events
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
.OrderByDescending(e => e.Start)
.ToList();
/// <summary>All entries in chronological order, for the ICS export.</summary>
public IReadOnlyList<Event> All =>
_events.OrderBy(e => e.Start).ToList();
private static List<Event> Parse(string yaml)
{
// Keys are lowercase single words (title, start, ...), which the camel
// case convention maps onto the PascalCase properties.
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
return raw
.Where(entry => entry.Title is not null && entry.Start is not null)
.Select(ToEvent)
.OfType<Event>()
.ToList();
}
private static Event? ToEvent(EventEntry entry)
{
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
{
// A malformed date drops the entry rather than crashing startup.
return null;
}
DateTime? end = null;
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
{
end = parsedEnd;
}
return new Event
{
Title = entry.Title!,
Start = start,
HasTime = hasTime,
End = end,
Location = entry.Location,
Note = entry.Note,
};
}
/// <summary>
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
/// caller knows whether to treat the entry as all-day.
/// </summary>
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
{
var trimmed = value.Trim();
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
{
when = dateOnly.ToDateTime(TimeOnly.MinValue);
hasTime = false;
return true;
}
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
if (DateTime.TryParseExact(trimmed, formats,
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
{
hasTime = true;
return true;
}
when = default;
hasTime = false;
return false;
}
/// <summary>
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
/// we control date parsing and can distinguish date-only from date-and-time.
/// </summary>
private sealed class EventEntry
{
public string? Title { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public string? Location { get; set; }
public string? Note { get; set; }
}
}
+42
View File
@@ -0,0 +1,42 @@
using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads simple <c>key: value</c> pairs from a Markdown file's YAML front
/// matter. Kept deliberately minimal (one value per line, no nesting) — this is
/// enough for titles and dates. YamlDotNet will take over once we need
/// structured metadata. Front-matter keys stay German because the Markdown
/// files are edited by German-speaking editors.
/// </summary>
public static class FrontMatter
{
/// <summary>
/// Returns the value of the given front-matter key, or <c>null</c> if the
/// file has no front matter or the key is missing. The key match is
/// case-insensitive; surrounding quotes on the value are removed.
/// </summary>
public static string? Read(string key, MarkdownDocument document, string source)
{
var block = document.Descendants<YamlFrontMatterBlock>().FirstOrDefault();
if (block is null)
{
return null;
}
var prefix = key + ":";
var frontMatter = source.Substring(block.Span.Start, block.Span.Length);
foreach (var line in frontMatter.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return trimmed[prefix.Length..].Trim().Trim('"');
}
}
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Builds an iCalendar (RFC 5545) document from the events so visitors can
/// subscribe to the calendar in their own app. Times are written as local
/// wall-clock times without a time zone, which is what a school calendar needs:
/// "19:30" should read as 19:30 everywhere.
/// </summary>
public static class IcsCalendar
{
private const string ProductId = "-//Elternbeirat IGMH//Termine//DE";
/// <summary>
/// Serialises the given entries into a single VCALENDAR document.
/// </summary>
public static string Build(IReadOnlyList<Event> events)
{
var sb = new StringBuilder();
AppendLine(sb, "BEGIN:VCALENDAR");
AppendLine(sb, "VERSION:2.0");
AppendLine(sb, $"PRODID:{ProductId}");
AppendLine(sb, "CALSCALE:GREGORIAN");
AppendLine(sb, "METHOD:PUBLISH");
AppendLine(sb, "X-WR-CALNAME:Elternbeirat IGMH");
foreach (var ev in events)
{
AppendEvent(sb, ev);
}
AppendLine(sb, "END:VCALENDAR");
return sb.ToString();
}
private static void AppendEvent(StringBuilder sb, Event ev)
{
AppendLine(sb, "BEGIN:VEVENT");
AppendLine(sb, $"UID:{Uid(ev)}");
// No live timestamp: a stable DTSTAMP keeps the feed byte-identical
// between requests, so caches and clients do not see spurious changes.
AppendLine(sb, "DTSTAMP:20000101T000000Z");
if (ev.HasTime)
{
AppendLine(sb, $"DTSTART:{Local(ev.Start)}");
if (ev.End is { } end)
{
AppendLine(sb, $"DTEND:{Local(end)}");
}
}
else
{
AppendLine(sb, $"DTSTART;VALUE=DATE:{Date(ev.Start)}");
// For an all-day event DTEND is exclusive: add one day so a single
// day shows as one day, and a range covers its last day.
var end = ev.End ?? ev.Start;
AppendLine(sb, $"DTEND;VALUE=DATE:{Date(end.AddDays(1))}");
}
AppendLine(sb, $"SUMMARY:{Escape(ev.Title)}");
if (!string.IsNullOrWhiteSpace(ev.Location))
{
AppendLine(sb, $"LOCATION:{Escape(ev.Location)}");
}
if (!string.IsNullOrWhiteSpace(ev.Note))
{
AppendLine(sb, $"DESCRIPTION:{Escape(ev.Note)}");
}
AppendLine(sb, "END:VEVENT");
}
/// <summary>
/// A UID that stays the same as long as the entry's title and start do, so a
/// re-subscribe updates the event instead of creating a duplicate.
/// </summary>
private static string Uid(Event ev)
{
var seed = $"{ev.Title}|{ev.Start:O}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"{Convert.ToHexString(hash)[..16].ToLowerInvariant()}@elternbeirat-igmh";
}
private static string Local(DateTime value) =>
value.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture);
private static string Date(DateTime value) =>
value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
/// <summary>Escapes the characters that are special in an ICS text value.</summary>
private static string Escape(string value) => value
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\r\n", "\\n")
.Replace("\n", "\\n");
// ICS lines are terminated with CRLF regardless of platform.
private static void AppendLine(StringBuilder sb, string line) =>
sb.Append(line).Append("\r\n");
}
+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; }
}
+57
View File
@@ -0,0 +1,57 @@
using Markdig;
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 = FrontMatter.Read("title", document, source) ?? slug;
var html = Markdown.ToHtml(source, pipeline);
return new Page { Slug = slug, Title = title, ContentHtml = html };
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Elternbeirat.Web.Services;
/// <summary>
/// A news post, read from a Markdown file in <c>Content/posts/</c>. Unlike a
/// <see cref="Page"/>, a post has a date and
/// is shown in a chronological list. The file name (without extension) is the
/// slug.
/// </summary>
public sealed class Post
{
/// <summary>
/// URL identifier of the post, taken from the file name without umlauts.
/// It appears in the route <c>/beitraege/{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>
/// Publication date from the front matter (<c>datum:</c>). Used to sort the
/// list newest first.
/// </summary>
public required DateOnly Date { get; init; }
/// <summary>
/// The post body rendered from Markdown to HTML (front matter excluded).
/// </summary>
public required string ContentHtml { get; init; }
}
+86
View File
@@ -0,0 +1,86 @@
using System.Globalization;
using Markdig;
namespace Elternbeirat.Web.Services;
/// <summary>
/// Reads the news posts from <c>Content/posts/*.md</c>, renders them to HTML
/// once at startup and keeps them in memory, sorted newest first. Registered as
/// a singleton because the content lives in the image and does not change at
/// runtime.
/// </summary>
public sealed class PostService
{
private readonly IReadOnlyList<Post> _posts;
private readonly IReadOnlyDictionary<string, Post> _postsBySlug;
/// <summary>
/// Reads every Markdown post from the <c>Content/posts</c> directory
/// below the application root and caches it.
/// </summary>
public PostService(IWebHostEnvironment environment)
{
var pipeline = new MarkdownPipelineBuilder()
.UseYamlFrontMatter()
.Build();
var directory = Path.Combine(environment.ContentRootPath, "Content", "posts");
var posts = new List<Post>();
if (Directory.Exists(directory))
{
foreach (var path in Directory.EnumerateFiles(directory, "*.md"))
{
posts.Add(Read(path, pipeline));
}
}
// Newest first for the list; a stable slug order breaks date ties.
posts.Sort((a, b) =>
{
var byDate = b.Date.CompareTo(a.Date);
return byDate != 0 ? byDate : string.CompareOrdinal(a.Slug, b.Slug);
});
_posts = posts;
_postsBySlug = posts.ToDictionary(p => p.Slug, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// All posts, newest first. Used for the overview list.
/// </summary>
public IReadOnlyList<Post> All => _posts;
/// <summary>
/// Returns the post for the given slug, or <c>null</c> if there is none.
/// The lookup is case-insensitive.
/// </summary>
public Post? Find(string slug) =>
_postsBySlug.GetValueOrDefault(slug);
private static Post Read(string path, MarkdownPipeline pipeline)
{
var slug = Path.GetFileNameWithoutExtension(path);
var source = File.ReadAllText(path);
var document = Markdown.Parse(source, pipeline);
var title = FrontMatter.Read("title", document, source) ?? slug;
var date = ParseDate(FrontMatter.Read("date", document, source));
var html = Markdown.ToHtml(source, pipeline);
return new Post { Slug = slug, Title = title, Date = date, ContentHtml = html };
}
/// <summary>
/// Parses the <c>datum:</c> value as an ISO date (yyyy-MM-dd). Falls back to
/// <see cref="DateOnly.MinValue"/> when the value is missing or malformed,
/// so a single bad file does not crash startup — it just sorts last.
/// </summary>
private static DateOnly ParseDate(string? value)
{
return DateOnly.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)
? date
: DateOnly.MinValue;
}
}
+105
View File
@@ -1,7 +1,112 @@
/* Base styles. Self-hosted system font stack — no external fonts, for privacy. */
html {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #222;
}
body {
margin: 0;
}
a {
color: #1f3a5f;
}
h1, h2, h3 {
line-height: 1.25;
color: #1f3a5f;
}
h1:focus { h1:focus {
outline: none; outline: none;
} }
/* Home page */
.intro {
font-size: 1.1rem;
color: #444;
}
.home-news {
margin-top: 2rem;
}
/* News list on /beitraege */
.post-list {
list-style: none;
padding: 0;
}
.post-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.post-list a {
font-size: 1.1rem;
text-decoration: none;
}
.post-list time,
.post-meta time {
display: block;
color: #777;
font-size: 0.85rem;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
/* Event list on /termine */
.event-list {
list-style: none;
padding: 0;
}
.event-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.event-list time {
display: block;
color: #777;
font-size: 0.85rem;
}
.event-title {
font-size: 1.1rem;
font-weight: 600;
}
.event-location::before {
content: " · ";
color: #777;
}
.event-location {
color: #555;
}
.event-note {
margin: 0.25rem 0 0;
color: #444;
}
.event-list-past {
color: #777;
}
.event-list-past .event-title {
font-weight: 400;
}
.valid.modified:not([type=checkbox]) { .valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050; outline: 1px solid #26b050;
} }
+1
View File
@@ -1,3 +1,4 @@
<Solution> <Solution>
<Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" /> <Project Path="Elternbeirat.Web/Elternbeirat.Web.csproj" />
<Project Path="Elternbeirat.Web.Tests/Elternbeirat.Web.Tests.csproj" />
</Solution> </Solution>
+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.
+129
View File
@@ -0,0 +1,129 @@
# Editing content
How to add or change the visitor-facing content of the site. All content lives
as files under `Elternbeirat.Web/Content/` — there is no database and no admin
interface. Every change needs a **commit** and a **rebuild** of the image, since
the content ships inside the image, not in a volume.
> **Language convention.** File and folder names, front-matter keys and YAML keys
> are English (`posts/`, `title:`, `start:`). The text a visitor reads stays
> German — that includes the *value* after a key (`title: Vorstandsteam`) and the
> Markdown body. So you write English keys with German values.
The three content kinds:
| Kind | Where | New entry = |
|---|---|---|
| Page | `Content/pages/*.md` | one Markdown file |
| Post (news) | `Content/posts/*.md` | one Markdown file |
| Event (calendar) | `Content/events.yml` | one entry in the list |
None of these touch a `.razor` file.
---
## Pages
A page is a standalone Markdown file in `Content/pages/`. The file name (without
`.md`) is the slug and therefore the URL: `vorstandsteam.md` is served at
`/vorstandsteam`.
Front matter at the top sets the title; the body is the content:
```markdown
---
title: Vorstandsteam
---
# Vorstandsteam
Hier stellt sich das Team vor …
```
- **Slugs have no umlauts.** Use `ueber-uns`, not `über-uns`; `foerderverein`,
not `förderverein`. The visible heading in the body may of course use umlauts.
- If you omit `title:`, the slug is used as the title — so always set it.
- Add the new page to the navigation only if it should appear there: edit
`Components/Layout/MainLayout.razor`. Pages that are only linked from other
pages (like the FAQ sub-pages) do not need a nav entry.
---
## Posts (news)
A post is a Markdown file in `Content/posts/`, same idea as a page but with a
date. Posts show up in the news list at `/beitraege`, newest first, and each has
its own URL at `/beitraege/<slug>`.
```markdown
---
title: Neue Sporthalle feierlich eröffnet
date: 2025-10-05
---
# Neue Sporthalle feierlich eröffnet
Der Text des Beitrags …
```
- `date:` is an ISO date, **`yyyy-MM-dd`**. It drives the sort order (newest
first) and the displayed date. A missing or malformed date sorts the post last
rather than breaking the build.
- The three most recent posts are also teased on the home page automatically —
nothing to do there.
---
## Events (calendar)
Events are **not** separate files. They all live in the single list
`Content/events.yml`. Add an entry to the list:
```yaml
- title: Elternbeiratssitzung
start: "2026-10-08 19:30"
location: Lehrerzimmer
note: Themen bitte vorab per E-Mail einreichen.
```
Keys:
| Key | Required | Meaning |
|---|---|---|
| `title` | yes | Short name shown in the list and calendar |
| `start` | yes | When it starts — see date formats below |
| `end` | no | End, for entries that span hours or days |
| `location` | no | e.g. `Aula`, `Mensa` |
| `note` | no | Free text shown below the entry |
**Date formats** for `start` and `end`:
- Date only → all-day event: `"2026-03-15"`
- Date with time: `"2026-03-15 19:30"` (24-hour clock)
Always keep the value in quotes so YAML treats it as text.
The overview at `/termine` splits the list automatically: entries today or later
appear under *Kommende Termine* (earliest first), past ones under *Vergangene
Termine* (most recent first). You do not sort the file yourself — order in the
YAML does not matter.
Visitors can subscribe to `/termine.ics` in their own calendar app; that feed is
generated from the same file, so a new entry appears there too.
> A malformed `start` drops just that one entry instead of breaking the whole
> page, so a typo in one event will not take the calendar down — but the entry
> silently disappears. If an event does not show up, check its `start` value.
---
## Publishing a change
A content change is not live until the image is rebuilt and redeployed:
1. Edit or add the file under `Content/`.
2. Check it locally with `dotnet run --project Elternbeirat.Web`. Content is read
once at startup, so after editing a file **restart the process** to see the
change (or use `dotnet watch` to restart on save automatically).
3. Commit the change.
4. Rebuild and redeploy the image — see [deployment](deployment.md).
+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."