From c0ecf2022d03066be1120f2cbe82a4c0c4780fb5 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sun, 7 Jun 2026 01:44:28 +0200 Subject: [PATCH] WEB-2: Self-hosted public site (publicsite-nginx + POST /api/publish, atomic swap) POST /api/publish: rendert SiteSnapshot->HTML in _staging_new/, atomic swap -> live/ (rename, ein Syscall). publicsite-nginx:alpine serviert live/ read-only auf Port 8081. Shared Volume api(rw)/publicsite(ro). Manager bleibt LAN-only. 5 neue Tests (atomic swap, UTF-8, mehrfach), 184/184 gruen. compose config OK. Vhost-Snippet + web-deploy.md (Deutsch) beigelegt; wartet auf Julian. --- GerbilManager.Tests/CmsPublishTests.cs | 103 +++++++++++++ GerbilManagerWebAPI/Cms/PublicSiteOptions.cs | 16 +++ GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs | 47 ++++++ GerbilManagerWebAPI/Program.cs | 3 + deploy/truenas/.env.example | 5 + deploy/truenas/compose.yaml | 25 ++++ deploy/truenas/nginx/publicsite.conf | 29 ++++ deploy/truenas/vhost-snippet.conf | 37 +++++ docs/web-deploy.md | 136 ++++++++++++++++++ 9 files changed, 401 insertions(+) create mode 100644 GerbilManager.Tests/CmsPublishTests.cs create mode 100644 GerbilManagerWebAPI/Cms/PublicSiteOptions.cs create mode 100644 deploy/truenas/nginx/publicsite.conf create mode 100644 deploy/truenas/vhost-snippet.conf create mode 100644 docs/web-deploy.md diff --git a/GerbilManager.Tests/CmsPublishTests.cs b/GerbilManager.Tests/CmsPublishTests.cs new file mode 100644 index 0000000..7ea36e9 --- /dev/null +++ b/GerbilManager.Tests/CmsPublishTests.cs @@ -0,0 +1,103 @@ +using GerbilManagerWebAPI.Endpoints; + +namespace GerbilManager.Tests; + +/// WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup. +public class CmsPublishTests +{ + private static string TempRoot() => + Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien() + { + var root = TempRoot(); + try + { + var files = new Dictionary + { + ["index.html"] = "start", + ["kontakt/index.html"] = "kontakt", + ["assets/site.css"] = "body {}", + }; + + await CmsEndpoints.PublishToDirectoryAsync(files, root); + + Assert.True(File.Exists(Path.Combine(root, "live", "index.html"))); + Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html"))); + Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css"))); + Assert.Equal("start", + await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"))); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version() + { + var root = TempRoot(); + try + { + await CmsEndpoints.PublishToDirectoryAsync( + new Dictionary { ["index.html"] = "version-1" }, root); + + await CmsEndpoints.PublishToDirectoryAsync( + new Dictionary { ["index.html"] = "version-2" }, root); + + var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")); + Assert.Equal("version-2", content); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap() + { + var root = TempRoot(); + try + { + await CmsEndpoints.PublishToDirectoryAsync( + new Dictionary { ["index.html"] = "x" }, root); + + Assert.False(Directory.Exists(Path.Combine(root, "_staging_new"))); + Assert.False(Directory.Exists(Path.Combine(root, "_old"))); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Publish_mehrfach_ohne_Fehler() + { + var root = TempRoot(); + try + { + for (int i = 1; i <= 3; i++) + { + await CmsEndpoints.PublishToDirectoryAsync( + new Dictionary { ["index.html"] = $"v{i}" }, root); + } + + Assert.Equal("v3", + await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"))); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + + [Fact] + public async Task Publish_UTF8_Inhalt_korrekt_gespeichert() + { + var root = TempRoot(); + try + { + const string german = "Züchter — Rennmäuse & mehr"; + await CmsEndpoints.PublishToDirectoryAsync( + new Dictionary { ["index.html"] = german }, root); + + var content = await File.ReadAllTextAsync( + Path.Combine(root, "live", "index.html"), + System.Text.Encoding.UTF8); + Assert.Equal(german, content); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } +} diff --git a/GerbilManagerWebAPI/Cms/PublicSiteOptions.cs b/GerbilManagerWebAPI/Cms/PublicSiteOptions.cs new file mode 100644 index 0000000..5713bfe --- /dev/null +++ b/GerbilManagerWebAPI/Cms/PublicSiteOptions.cs @@ -0,0 +1,16 @@ +namespace GerbilManagerWebAPI.Cms +{ + /// + /// WEB-2: path where POST /api/publish writes the rendered static site. + /// Env var: PublicSite__RootPath (empty = publish disabled, returns 503). + /// In production this volume is shared with the publicsite-nginx container. + /// + public sealed class PublicSiteOptions + { + public const string SectionName = "PublicSite"; + + public string? RootPath { get; set; } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs index 339ac9a..af52c97 100644 --- a/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using GerbilManagerWebAPI.Cms; @@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace GerbilManagerWebAPI.Endpoints { @@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); }); + // ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ---- + api.MapPost("/publish", async (ApplicationContext db, IOptions opts) => + { + if (!opts.Value.IsConfigured) + return Results.Problem( + detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.", + statusCode: 503, + title: "PublicSite nicht konfiguriert"); + + var snapshot = await new SiteSnapshotService(db).BuildAsync(); + var files = SiteRenderer.Render(snapshot); + await PublishToDirectoryAsync(files, opts.Value.RootPath!); + return Results.Ok(new { filesPublished = files.Count }); + }); + // ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten) // und liefert die Datei mit passendem Content-Type aus. Relative // Links/CSS der gerenderten Seite funktionieren dadurch im @@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints return app; } + /// + /// WEB-2: Writes rendered files to /_staging_new, then + /// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial). + /// + internal static async Task PublishToDirectoryAsync( + IReadOnlyDictionary files, string rootPath) + { + var stagingDir = Path.Combine(rootPath, "_staging_new"); + var liveDir = Path.Combine(rootPath, "live"); + var oldDir = Path.Combine(rootPath, "_old"); + + if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true); + Directory.CreateDirectory(stagingDir); + + foreach (var (relativePath, content) in files) + { + var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar); + var fullPath = Path.Combine(stagingDir, normalPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8); + } + + // Atomic swap: _staging_new → live + if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); + if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir); + Directory.Move(stagingDir, liveDir); + try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); } + catch { /* non-fatal — old dir gone on next publish */ } + } + /// WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS). private static string PreviewContentType(string path) => path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8" diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index ae47609..0257077 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -48,6 +48,9 @@ builder.Services.AddHttpClient( // FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client). builder.Services.AddHttpClient( http => http.Timeout = TimeSpan.FromSeconds(60)); +// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled) +builder.Services.AddOptions() + .BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName); // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // AR-3: persist the key ring so encrypted passwords survive image redeployments. diff --git a/deploy/truenas/.env.example b/deploy/truenas/.env.example index b7c68d1..85f1197 100644 --- a/deploy/truenas/.env.example +++ b/deploy/truenas/.env.example @@ -21,6 +21,11 @@ KEYS_PATH=/mnt/SSD/gerbil/keys # Backup-Rotation: Anzahl Tage (Standard: 7) BACKUP_KEEP_DAYS=7 +# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest) +PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite +# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter) +PUBLICSITE_PORT=8081 + # KI-Funktionen (Verkaufstext + Posteingang-Entwurf) # Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md # Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing) diff --git a/deploy/truenas/compose.yaml b/deploy/truenas/compose.yaml index 359cf62..e484f3e 100644 --- a/deploy/truenas/compose.yaml +++ b/deploy/truenas/compose.yaml @@ -48,9 +48,12 @@ services: AI__BaseUrl: "${AI__BaseUrl:-}" AI__ApiKey: "${AI__ApiKey:-}" AI__Model: "${AI__Model:-gemini-flash-latest}" + # WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt + PublicSite__RootPath: /data/publicsite volumes: - photos:/data/photos - keys:/data/keys + - publicsite:/data/publicsite depends_on: db: condition: service_healthy @@ -74,6 +77,21 @@ services: api: condition: service_healthy + # --- nginx Public Site (WEB-2) --- + # Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume). + # SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen. + # Julian's externer nginx-Proxy leitet auf Port 8081 weiter. + publicsite: + image: nginx:alpine + restart: unless-stopped + ports: + - "${PUBLICSITE_PORT:-8081}:80" + volumes: + - publicsite:/usr/share/nginx/html:ro + - ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - api + # --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) --- backup: image: postgres:17-alpine @@ -122,3 +140,10 @@ volumes: type: none o: bind device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" + # WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro). + publicsite: + driver: local + driver_opts: + type: none + o: bind + device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}" diff --git a/deploy/truenas/nginx/publicsite.conf b/deploy/truenas/nginx/publicsite.conf new file mode 100644 index 0000000..a6bd9d0 --- /dev/null +++ b/deploy/truenas/nginx/publicsite.conf @@ -0,0 +1,29 @@ +# GerbilManager — publicsite nginx (WEB-2) +# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes. +# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager. +server { + listen 80; + root /usr/share/nginx/html/live; + index index.html; + charset utf-8; + + # Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen) + location / { + try_files $uri $uri/index.html =404; + add_header Cache-Control "no-cache, must-revalidate"; + add_header X-Content-Type-Options "nosniff"; + add_header X-Frame-Options "SAMEORIGIN"; + } + + # CSS/Bilder: kurze TTL (1 Tag) + location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ { + try_files $uri =404; + expires 1d; + add_header Cache-Control "public, max-age=86400"; + } + + # Kein Zugriff auf Staging-Verzeichnisse + location ~ ^/_(staging_new|old)/ { + return 403; + } +} diff --git a/deploy/truenas/vhost-snippet.conf b/deploy/truenas/vhost-snippet.conf new file mode 100644 index 0000000..2be0551 --- /dev/null +++ b/deploy/truenas/vhost-snippet.conf @@ -0,0 +1,37 @@ +# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2) +# In Julians bestehenden nginx-Reverse-Proxy einfuegen. +# ersetzen sobald der Hostname feststeht (Julian liefert ihn). +# +# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081). +# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen — +# er hat keine Authentifizierung und muss LAN-only bleiben. +server { + listen 80; + server_name ; + + location / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Kein Buffering fuer kleine statische HTML-Seiten + proxy_buffering off; + } +} + +# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot): +# server { +# listen 443 ssl; +# server_name ; +# ssl_certificate /etc/letsencrypt/live//fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live//privkey.pem; +# location / { +# proxy_pass http://127.0.0.1:8081; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } +# } diff --git a/docs/web-deploy.md b/docs/web-deploy.md new file mode 100644 index 0000000..7f35977 --- /dev/null +++ b/docs/web-deploy.md @@ -0,0 +1,136 @@ +# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS) + +> **Zielgruppe:** Julian. +> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager. +> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081). + +--- + +## Architektur + +``` +Internet + | HTTPS/HTTP + v +Julians nginx-Reverse-Proxy (laeuft schon auf NAS) + | proxy_pass http://127.0.0.1:8081 + v +publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/ + | (Shared Volume, read-only) + | [POST /api/publish im Manager schreibt in dasselbe Volume] + v +api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/ + | (Shared Volume, read-write) + v +Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert +``` + +**SICHERHEIT — harte Bedingung:** +- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet. +- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only. +- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML. + +--- + +## Erstinstallation + +### 1. Verzeichnis anlegen + +```bash +mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite +``` + +Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig. +Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an. + +### 2. .env erganzen + +In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen): + +```env +PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite +PUBLICSITE_PORT=8081 +``` + +### 3. Compose-Stack neu starten + +```bash +cd /opt/gerbilmanager +docker compose -f deploy/truenas/compose.yaml up -d +``` + +Der neue `publicsite`-Container startet und serviert Port 8081. +Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer). + +### 4. Julians externen nginx konfigurieren + +Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen +(als eigenen `server`-Block oder per `include`): + +```bash +# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available): +nano /etc/nginx/conf.d/gerbilmanager-public.conf +# durch den tatsaechlichen Hostnamen ersetzen +nginx -t && nginx -s reload +``` + +--- + +## Seite veroeffentlichen (Publish-Ablauf) + +1. Im Manager einloggen (http://\/) +2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen** +3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus. + +**Was passiert intern:** +``` +POST /api/publish + → API baut SiteSnapshot aus DB (alle Published-Seiten) + → SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map) + → Schreibt Dateien nach /data/publicsite/_staging_new/ + → Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben) + → publicsite-nginx serviert beim naechsten Request sofort den neuen Stand + → Kein Container-Restart, kein Image-Rebuild, kein CI +Response: { "filesPublished": N } +``` + +**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`. + +--- + +## Verifikation + +```bash +# publicsite-Container laeuft? +docker compose -f deploy/truenas/compose.yaml ps publicsite + +# Seite lokal abrufbar? +curl -s http://localhost:8081/ | head -5 + +# live/-Verzeichnis gefuellt? +ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/ + +# Oeffentlich erreichbar (nach DNS-Propagation)? +curl -s http:/// | grep "Kleine Chaoten" +``` + +--- + +## Sicherheitstrennung (Pflichtcheck) + +| Was | Port | Internet-exponiert? | +|-----|------|---------------------| +| Manager (api + frontend) | 80 | **NEIN** — LAN-only | +| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy | +| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only | + +Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern) +sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert. +Sie sind ausschliesslich im Heimnetz erreichbar. + +--- + +## Hostname noch ausstehend + +`` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter. +Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.