using System.IO.Compression; using GerbilManagerWebAPI.Dtos; using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Import.Rpro3; using Microsoft.AspNetCore.Http.HttpResults; namespace GerbilManagerWebAPI.Endpoints { /// /// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output. /// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes) /// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run /// against Julian's DB under god-supervision after he approves the dry-run. /// /// RPRO3 — vollwertiger RennmausPro-III-Backup-Importer (Upload via "Hilfe"): /// POST /import/rpro3/analyze -> .backup (+ optional bilder.zip) entpacken, parsen, deduplizieren; /// schreibt NICHTS. Liefert Zählungen, Top-Merges, mehrdeutige Namen. /// POST /import/rpro3/execute -> idempotenter Import (deterministische GUIDs). /// public static class ImportEndpoints { public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/import").WithTags("Import"); group.MapPost("/dry-run", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var report = await new ImportService(db, config, env).RunAsync(execute: false); return TypedResults.Ok(report); }); group.MapPost("/execute", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var report = await new ImportService(db, config, env).RunAsync(execute: true); return TypedResults.Ok(report); }); group.MapPost("/ingest-resolved", async Task> ( ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { var result = await new IngestResolvedService(db, config, env).RunAsync(); return TypedResults.Ok(result); }); // Upload-Variante: lädt eine resolved_import.json (+ optional Fotos-ZIP) hoch, legt sie in // dem Verzeichnis ab, aus dem der Ingest liest, und ingested direkt. Damit lässt sich der // Import gegen PROD fahren, OHNE die Datei per SSH/docker cp in den Container zu kopieren // (der lokal generierte Payload geht per HTTP an die Prod-API). Abwärtskompatibel — der // bestehende /ingest-resolved-Endpoint bleibt unverändert. group.MapPost("/ingest-resolved/upload", async Task, BadRequest>> ( IFormFile resolved, IFormFile? photos, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { if (resolved.Length == 0) return TypedResults.BadRequest("resolved_import.json ist leer."); var sourceDir = IngestResolvedService.ResolveSourceDir(config, env); Directory.CreateDirectory(sourceDir); // resolved_import.json an den erwarteten Ort schreiben. var destPath = Path.Combine(sourceDir, "resolved_import.json"); await using (var fs = File.Create(destPath)) await resolved.CopyToAsync(fs); // Optionale Fotos-ZIP in den Source-Dir entpacken (Einträge relativ), damit der // Foto-Kopierschritt (Path.Combine(sourceDir, relPath)) neue Fotos findet. if (photos is not null) { await using var zs = photos.OpenReadStream(); using var seekable = await ToSeekable(zs); ExtractZipInto(seekable, sourceDir); } var result = await new IngestResolvedService(db, config, env).RunAsync(); return TypedResults.Ok(result); }).WithTags("Import").DisableAntiforgery(); // ── RPRO3 ────────────────────────────────────────────────────────── group.MapPost("/rpro3/analyze", async Task, BadRequest>> ( IFormFile backup, IFormFile? images, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { try { var reader = new Rpro3Reader(); await using var bs = backup.OpenReadStream(); using var seekable = await ToSeekable(bs); var data = reader.ReadFromBackup(seekable, out var workDir); try { IReadOnlySet? photoFiles = null; if (images is not null) { await using var imgStream = images.OpenReadStream(); using var imgSeekable = await ToSeekable(imgStream); photoFiles = ListZipImageNames(imgSeekable); } var service = new Rpro3ImportService(db, config, env); var result = await service.AnalyzeAsync(data, images is not null, photoFiles); return TypedResults.Ok(result); } finally { TryDeleteDir(workDir); } } catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); } }).WithTags("Import").DisableAntiforgery(); group.MapPost("/rpro3/execute", async Task, BadRequest>> ( IFormFile backup, IFormFile? images, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => { try { var reader = new Rpro3Reader(); await using var bs = backup.OpenReadStream(); using var seekable = await ToSeekable(bs); var data = reader.ReadFromBackup(seekable, out var workDir); try { Dictionary? photoPaths = null; if (images is not null) { await using var imgStream = images.OpenReadStream(); using var imgSeekable = await ToSeekable(imgStream); photoPaths = ExtractZipImages(imgSeekable, workDir); } var service = new Rpro3ImportService(db, config, env); var result = await service.ExecuteAsync(data, workDir, images is not null, photoPaths); return TypedResults.Ok(result); } finally { TryDeleteDir(workDir); } } catch (Rpro3FormatException ex) { return TypedResults.BadRequest(ex.Message); } }).WithTags("Import").DisableAntiforgery(); return app; } // IFormFile-Streams sind nicht immer seekbar (ZipArchive braucht Seek) → in MemoryStream kopieren. private static async Task ToSeekable(Stream s) { var ms = new MemoryStream(); await s.CopyToAsync(ms); ms.Position = 0; return ms; } private static readonly string[] ImageExts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; private static IReadOnlySet ListZipImageNames(Stream zip) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); foreach (var e in archive.Entries) if (!string.IsNullOrEmpty(e.Name) && ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant())) set.Add(e.Name); return set; } // Bilder ins workDir entpacken; Rückgabe: lowercase-Dateiname → absoluter Pfad. private static Dictionary ExtractZipImages(Stream zip, string workDir) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var imgDir = Path.Combine(workDir, "bilder"); Directory.CreateDirectory(imgDir); using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); foreach (var e in archive.Entries) { if (string.IsNullOrEmpty(e.Name) || !ImageExts.Contains(Path.GetExtension(e.Name).ToLowerInvariant())) continue; var dest = Path.Combine(imgDir, Guid.NewGuid().ToString("N") + Path.GetExtension(e.Name)); try { e.ExtractToFile(dest, overwrite: true); map[e.Name] = dest; } catch { /* defektes Archiv-Entry überspringen */ } } return map; } // Entpackt ein ZIP relativ in destDir (Einträge behalten ihren relativen Pfad). Schützt vor // Zip-Slip (Einträge, die aus destDir ausbrechen wollen) und überspringt Verzeichnis-Einträge. private static void ExtractZipInto(Stream zip, string destDir) { var root = Path.GetFullPath(destDir); using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); foreach (var e in archive.Entries) { if (string.IsNullOrEmpty(e.Name)) continue; // Verzeichnis-Eintrag var target = Path.GetFullPath(Path.Combine(root, e.FullName)); if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !string.Equals(target, root, StringComparison.Ordinal)) continue; // Zip-Slip abwehren Directory.CreateDirectory(Path.GetDirectoryName(target)!); try { e.ExtractToFile(target, overwrite: true); } catch { /* defektes Archiv-Entry überspringen */ } } } private static void TryDeleteDir(string dir) { try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } } }