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