Compare commits
6 Commits
feature/op
...
30b2fff775
| Author | SHA1 | Date | |
|---|---|---|---|
| 30b2fff775 | |||
| c0ecf2022d | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| c452b69dd6 |
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||||
|
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<string, string>
|
||||||
|
{
|
||||||
|
["index.html"] = "<html>start</html>",
|
||||||
|
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||||
|
["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("<html>start</html>",
|
||||||
|
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<string, string> { ["index.html"] = "version-1" }, root);
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["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<string, string> { ["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<string, string> { ["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 = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Cms
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublicSiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PublicSite";
|
||||||
|
|
||||||
|
public string? RootPath { get; set; }
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using GerbilManagerWebAPI.Cms;
|
using GerbilManagerWebAPI.Cms;
|
||||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
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());
|
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<PublicSiteOptions> 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)
|
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||||
|
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task PublishToDirectoryAsync(
|
||||||
|
IReadOnlyDictionary<string, string> 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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||||
private static string PreviewContentType(string path) =>
|
private static string PreviewContentType(string path) =>
|
||||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
|||||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||||
|
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||||
|
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ KEYS_PATH=/mnt/SSD/gerbil/keys
|
|||||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=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)
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ services:
|
|||||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
AI__ApiKey: "${AI__ApiKey:-}"
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||||
|
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||||
|
PublicSite__RootPath: /data/publicsite
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
- keys:/data/keys
|
- keys:/data/keys
|
||||||
|
- publicsite:/data/publicsite
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,6 +77,21 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
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 <DOMAIN> 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-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||||
backup:
|
backup:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -122,3 +140,10 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
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}"
|
||||||
|
|||||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||||
|
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||||
|
# <DOMAIN> 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 <DOMAIN>;
|
||||||
|
|
||||||
|
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 <DOMAIN>;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/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;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -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
|
||||||
|
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seite veroeffentlichen (Publish-Ablauf)
|
||||||
|
|
||||||
|
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||||
|
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://<DOMAIN>/ | 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
|
||||||
|
|
||||||
|
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||||
|
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||||
@@ -110,3 +110,30 @@ test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
|
|||||||
await expandBtn.click({ force: true })
|
await expandBtn.click({ force: true })
|
||||||
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
||||||
|
await page.goto('/rennmaeuse/fridolin/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const panel = page.locator('.stammbaum-litters-panel')
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
await expect(panel).toContainText(t.littersTitle)
|
||||||
|
await expect(panel).toContainText('Wurf K')
|
||||||
|
await expect(panel).toContainText('5')
|
||||||
|
|
||||||
|
// Link-Klick → Wurf-Detailseite
|
||||||
|
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
|
||||||
|
await expect(wurfLink).toBeVisible()
|
||||||
|
await wurfLink.click()
|
||||||
|
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
|
|||||||
expect(codes).toContain('mythg')
|
expect(codes).toContain('mythg')
|
||||||
expect(codes).toContain('ger')
|
expect(codes).toContain('ger')
|
||||||
expect(codes).toContain('arb')
|
expect(codes).toContain('arb')
|
||||||
expect(codes).toHaveLength(5)
|
expect(codes).toContain('disney')
|
||||||
|
expect(codes).toContain('pokemon')
|
||||||
|
expect(codes).toContain('encities')
|
||||||
|
expect(codes).toContain('hrcities')
|
||||||
|
expect(codes).toContain('usstates')
|
||||||
|
expect(codes).toHaveLength(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('every usage has a non-empty label', () => {
|
it('every usage has a non-empty label', () => {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
|
|||||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
{ code: 'ger', label: 'Deutsch' },
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
{ code: 'arb', label: 'Arabisch' },
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
{ code: 'disney', label: 'Disney' },
|
||||||
|
{ code: 'pokemon', label: 'Pokémon' },
|
||||||
|
{ code: 'encities', label: 'Englische Städte' },
|
||||||
|
{ code: 'hrcities', label: 'Kroatische Städte' },
|
||||||
|
{ code: 'usstates', label: 'US-Bundesstaaten' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import Tree from 'react-d3-tree'
|
|||||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
|
import { listLitters } from '../api/litters'
|
||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||||
import type { Gender, Gerbil } from '../api/types'
|
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel } from '../format/labels'
|
import { formatDate, genderLabel } from '../format/labels'
|
||||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||||
@@ -156,6 +157,13 @@ export default function StammbaumPage() {
|
|||||||
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
||||||
: t.inbreeding.unavailable
|
: t.inbreeding.unavailable
|
||||||
|
|
||||||
|
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
|
||||||
|
const rootLitters = useApi(
|
||||||
|
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
const rootLitterItems = rootLitters.data?.items ?? []
|
||||||
|
|
||||||
/* ── react-d3-tree-Daten ── */
|
/* ── react-d3-tree-Daten ── */
|
||||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||||
@@ -315,6 +323,10 @@ export default function StammbaumPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="stammbaum-layout">
|
||||||
|
{rootLitterItems.length > 0 && (
|
||||||
|
<LittersPanel litters={rootLitterItems} t={t} />
|
||||||
|
)}
|
||||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||||
{view && (
|
{view && (
|
||||||
<Tree
|
<Tree
|
||||||
@@ -335,6 +347,7 @@ export default function StammbaumPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ul className="stammbaum-hints">
|
<ul className="stammbaum-hints">
|
||||||
<li>{t.tapHint}</li>
|
<li>{t.tapHint}</li>
|
||||||
<li>{t.hintName}</li>
|
<li>{t.hintName}</li>
|
||||||
@@ -417,6 +430,36 @@ function PedigreeCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
|
||||||
|
|
||||||
|
function LittersPanel({
|
||||||
|
litters,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
litters: Litter[]
|
||||||
|
t: { littersTitle: string; littersJunge: string }
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
|
||||||
|
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
|
||||||
|
<ul className="stammbaum-litters-panel__list">
|
||||||
|
{litters.map((l) => (
|
||||||
|
<li key={l.id}>
|
||||||
|
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
|
||||||
|
<span className="stammbaum-litters-panel__name">{l.name}</span>
|
||||||
|
{l.totalBorn != null && (
|
||||||
|
<span className="stammbaum-litters-panel__born">
|
||||||
|
{l.totalBorn} {t.littersJunge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SexIcon({ gender }: { gender: Gender }) {
|
function SexIcon({ gender }: { gender: Gender }) {
|
||||||
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,9 +45,132 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
|
||||||
|
|
||||||
|
.stammbaum-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: Würfe-Panel links vom Baum. */
|
||||||
|
.stammbaum-litters-panel {
|
||||||
|
flex: none;
|
||||||
|
width: 148px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link:hover {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__name {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__born {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stammbaum-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel {
|
||||||
|
width: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__title {
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__list {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-height: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__link {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-litters-panel__born {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Canvas behält explizite Höhe in column-Richtung. */
|
||||||
|
.stammbaum-canvas {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.stammbaum-canvas {
|
.stammbaum-canvas {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
height: clamp(18rem, 62dvh, 46rem);
|
height: clamp(18rem, 62dvh, 46rem);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
|
|||||||
@@ -358,6 +358,9 @@ export const de = {
|
|||||||
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
hintName: 'Namenslink: Tierakte öffnen',
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
hintExpand: '+: weitere Vorfahren nachladen',
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
|
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
||||||
|
littersTitle: 'Würfe',
|
||||||
|
littersJunge: 'Junge',
|
||||||
zoomIn: 'Vergrößern',
|
zoomIn: 'Vergrößern',
|
||||||
zoomOut: 'Verkleinern',
|
zoomOut: 'Verkleinern',
|
||||||
zoomFit: 'Ansicht einpassen',
|
zoomFit: 'Ansicht einpassen',
|
||||||
|
|||||||
Reference in New Issue
Block a user