diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 876afe6..f452b88 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -1,13 +1,14 @@ namespace BigRegister.Api.Data; /// -/// Stored document metadata. The demo deliberately keeps NO file content — only -/// metadata — which is enough for status/delete/link and avoids holding PII bytes -/// in memory. A real backend persists the bytes to blob storage keyed by DocumentId. +/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on +/// restart) purely so re-opened wizards can preview/download what was uploaded — a +/// real backend persists them to blob storage keyed by DocumentId. Bytes are never +/// serialized into a JSON response; only the dedicated content endpoint streams them. /// public sealed record StoredDocument( string DocumentId, string LocalId, string CategoryId, string WizardId, - string FileName, long SizeBytes, string Owner, DateTimeOffset UploadedAt) + string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) { public bool Linked { get; set; } } @@ -28,9 +29,9 @@ public static class DocumentStore private static readonly List _audit = new(); private static readonly object _gate = new(); - public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, long sizeBytes, string owner) + public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner) { - var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, sizeBytes, owner, DateTimeOffset.UtcNow); + var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow); lock (_gate) _docs[doc.DocumentId] = doc; Audit("upload", doc.DocumentId, categoryId, owner); return doc; diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 8807d49..ff4b273 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -107,11 +107,25 @@ api.MapPost("/uploads", async (HttpRequest request) => var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); - var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.Length, DocumentStore.DemoOwner); + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); }) .ExcludeFromDescription(); +// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline +// for pdf/image (browser renders it), attachment otherwise (download). +api.MapGet("/uploads/{documentId}/content", (string documentId) => +{ + var doc = DocumentStore.Get(documentId); + if (doc is null) return Results.NotFound(); + var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); + return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound); + // Poll-on-return: which of these client localIds have arrived at the BFF. api.MapGet("/uploads/status", (string? localIds) => { diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index ed37877..68a265c 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -164,6 +164,24 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); } + [Fact] + public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf() + { + var doc = await Upload(Guid.NewGuid().ToString()); + var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content"); + res.EnsureSuccessStatusCode(); + Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType); + Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync()); + // pdf/image → inline (no attachment disposition) so the browser previews it + Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType); + } + + [Fact] + public async Task Upload_content_404_for_unknown_document() + { + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode); + } + [Fact] public async Task Upload_rejects_wrong_type() {