using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using GerbilManagerWebAPI.Cms; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Models; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace GerbilManagerWebAPI.Endpoints { /// /// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export /// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks /// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the /// rest of the API. /// public static class CmsEndpoints { public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app) { var api = app.MapGroup("/api").WithTags("CMS"); // ---- snapshot (consumed by the static renderer) ---- api.MapGet("/site-snapshot", async (ApplicationContext db) => TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync())); // ---- static render dry-run (WEB-1: returns rendered file map as JSON) ---- api.MapGet("/render-site", async (ApplicationContext db) => { var snapshot = await new SiteSnapshotService(db).BuildAsync(); var files = SiteRenderer.Render(snapshot); 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 // Vorschau-iframe genauso wie später auf der echten Webseite. ---- api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) => { var snapshot = await new SiteSnapshotService(db).BuildAsync(); var files = SiteRenderer.Render(snapshot); var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/'); if (!files.TryGetValue(key, out var content) && !files.TryGetValue($"{key}/index.html", out content)) { return Results.NotFound(); } return Results.Content(content, PreviewContentType(key)); }); // ---- pages ---- api.MapGet("/pages", async (ApplicationContext db) => TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug) .Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status)) .ToListAsync())); api.MapGet("/pages/{slug}", async Task, NotFound>> (string slug, ApplicationContext db) => { var p = await db.Pages.AsNoTracking().Include(x => x.Blocks) .FirstOrDefaultAsync(x => x.Slug == slug); return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p)); }); api.MapPost("/pages", async Task, ValidationProblem, Conflict>> (PageInput input, ApplicationContext db) => { if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title)) return TypedResults.ValidationProblem(new Dictionary { ["slug"] = ["Slug and title are required."] }); if (await db.Pages.AnyAsync(p => p.Slug == input.Slug)) return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists."); var page = new Page { Id = Guid.NewGuid(), Slug = input.Slug, Title = input.Title, SeoDescription = input.SeoDescription, Status = input.Status ?? PageStatus.Draft, }; db.Pages.Add(page); await db.SaveChangesAsync(); return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page)); }); api.MapPut("/pages/{id:guid}", async Task> (Guid id, PageInput input, ApplicationContext db) => { var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id); if (page is null) return TypedResults.NotFound(); page.Slug = input.Slug; page.Title = input.Title; page.SeoDescription = input.SeoDescription; if (input.Status is PageStatus s) page.Status = s; await db.SaveChangesAsync(); return TypedResults.NoContent(); }); api.MapDelete("/pages/{id:guid}", async Task> (Guid id, ApplicationContext db) => { var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id); if (page is null) return TypedResults.NotFound(); db.Pages.Remove(page); // cascades blocks await db.SaveChangesAsync(); return TypedResults.NoContent(); }); // ---- blocks ---- api.MapPost("/pages/{pageId:guid}/blocks", async Task, NotFound, BadRequest>> ( Guid pageId, BlockInput input, ApplicationContext db) => { if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound(); if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined)) return TypedResults.BadRequest("Block data must be a JSON object."); int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId) .Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1); var block = new Block { Id = Guid.NewGuid(), PageId = pageId, Order = order, Type = input.Type, Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}", }; db.Blocks.Add(block); await db.SaveChangesAsync(); return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block)); }); api.MapPut("/blocks/{id:guid}", async Task>> ( Guid id, BlockInput input, ApplicationContext db) => { var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id); if (block is null) return TypedResults.NotFound(); if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined)) return TypedResults.BadRequest("Block data must be a JSON object."); block.Type = input.Type; if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText(); if (input.Order is int o) block.Order = o; await db.SaveChangesAsync(); return TypedResults.NoContent(); }); api.MapDelete("/blocks/{id:guid}", async Task> (Guid id, ApplicationContext db) => { var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id); if (block is null) return TypedResults.NotFound(); db.Blocks.Remove(block); await db.SaveChangesAsync(); return TypedResults.NoContent(); }); // reorder: body = ordered block ids; Order set to the list index api.MapPut("/pages/{pageId:guid}/blocks/order", async Task>> ( Guid pageId, BlockOrderInput input, ApplicationContext db) => { var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync(); if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound(); var byId = blocks.ToDictionary(b => b.Id); if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid))) return TypedResults.BadRequest("blockIds must list exactly the page's block ids."); for (int i = 0; i < input.BlockIds.Count; i++) byId[input.BlockIds[i]].Order = i; await db.SaveChangesAsync(); return TypedResults.NoContent(); }); 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" : path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8" : path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8" : "text/html; charset=utf-8"; private static BlockDto ToBlockDto(Block b) => new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data)); private static PageDto ToPageDto(Page p) => new( p.Id, p.Slug, p.Title, p.SeoDescription, p.Status, p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList()); } }