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.