using System.Security.Claims; using Books.Api.Authorization; using Books.Api.Commands.Attachments; using Books.Api.Domain; using Books.Api.Domain.Attachments; using Books.Api.EventFlow.ReadModels; using Books.Api.EventFlow.Repositories; using Books.Api.Infrastructure.FileStorage; using EventFlow; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Books.Api.Controllers; /// /// REST API for attachment (bilag) file operations. /// GraphQL doesn't handle file uploads well, so we use REST for this. /// [ApiController] [Route("api/attachments")] [Authorize] public class AttachmentController( ICommandBus commandBus, IAttachmentRepository attachmentRepository, IFileStorageService fileStorage, ICompanyAccessService companyAccess, ILogger logger) : ControllerBase { /// /// Upload one or more attachments for a company. /// /// Company ID /// Optional draft ID to link attachments to /// Files to upload [HttpPost("upload")] [RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total public async Task Upload( [FromQuery] string companyId, [FromQuery] string? draftId, [FromForm] List files, CancellationToken cancellationToken) { // Validate company access var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) { return Unauthorized(new { error = "NOT_AUTHENTICATED", message = "You must be authenticated to upload attachments" }); } // Check if user can write (Accountant or Owner role) var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken); if (!canWrite) { return Forbid(); } if (files.Count == 0) { return BadRequest(new { error = "NO_FILES", message = "No files provided" }); } var results = new List(); foreach (var file in files) { try { // Validate file size (10MB per file) if (file.Length > 10 * 1024 * 1024) { results.Add(new { success = false, fileName = file.FileName, error = "FILE_TOO_LARGE", message = $"File '{file.FileName}' exceeds 10MB limit" }); continue; } // Store file await using var stream = file.OpenReadStream(); var storageResult = await fileStorage.StoreAsync( companyId, file.FileName, file.ContentType, stream, cancellationToken); // Create attachment aggregate var attachmentId = AttachmentId.New; var command = new UploadAttachmentCommand( attachmentId, companyId, storageResult.StoredFileName, file.FileName, file.ContentType, storageResult.FileSize, storageResult.StoragePath, userId, draftId); await commandBus.PublishAsync(command, cancellationToken); // Wait briefly for read model to be updated (eventual consistency) await Task.Delay(100, cancellationToken); var attachment = await attachmentRepository.GetByIdAsync(attachmentId.Value, cancellationToken); results.Add(new { success = true, id = attachmentId.Value, fileName = file.FileName, fileType = file.ContentType, fileSize = storageResult.FileSize, uploadedAt = attachment?.UploadedAt ?? DateTimeOffset.UtcNow, url = fileStorage.GetDownloadUrl(storageResult.StoragePath) }); } catch (DomainException ex) { logger.LogWarning(ex, "Failed to upload file {FileName}", file.FileName); results.Add(new { success = false, fileName = file.FileName, error = ex.Code, message = ex.MessageDanish }); } catch (Exception ex) { logger.LogError(ex, "Unexpected error uploading file {FileName}", file.FileName); results.Add(new { success = false, fileName = file.FileName, error = "UPLOAD_FAILED", message = "An unexpected error occurred" }); } } return Ok(new { attachments = results }); } /// /// Get attachments for a draft. /// [HttpGet("draft/{draftId}")] public async Task GetByDraft(string draftId, CancellationToken cancellationToken) { var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken); if (attachments.Count == 0) { return Ok(new { attachments = Array.Empty() }); } // Validate company access (use first attachment's company) var access = await companyAccess.GetAccessAsync(attachments[0].CompanyId, cancellationToken); if (access == null) { return Forbid(); } var result = attachments.Select(a => new { id = a.Id, fileName = a.OriginalFileName, fileType = a.ContentType, fileSize = a.FileSize, uploadedAt = a.UploadedAt.ToString("O"), uploadedBy = a.UploadedBy, url = fileStorage.GetDownloadUrl(a.StoragePath) }); return Ok(new { attachments = result }); } /// /// Download an attachment by storage path. /// [HttpGet("{*storagePath}")] public async Task Download(string storagePath, CancellationToken cancellationToken) { // Validate path to prevent directory traversal attacks if (string.IsNullOrWhiteSpace(storagePath) || storagePath.Contains("..") || storagePath.Contains("~") || Path.IsPathRooted(storagePath) || storagePath.StartsWith("/") || storagePath.StartsWith("\\")) { logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath); return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" }); } var file = await fileStorage.GetAsync(storagePath, cancellationToken); if (file == null) { return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" }); } return File(file.Content, file.ContentType, file.FileName); } /// /// Delete an attachment. /// Note: Per Bogføringsloven § 6, attachments linked to transactions /// cannot be deleted within the 5-year retention period. /// [HttpDelete("{id}")] public async Task Delete( string id, [FromQuery] string reason, CancellationToken cancellationToken) { var attachment = await attachmentRepository.GetByIdAsync(id, cancellationToken); if (attachment == null) { return NotFound(new { error = "ATTACHMENT_NOT_FOUND", message = "Attachment not found" }); } // Validate company access - need write permission to delete var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) { return Unauthorized(); } var canWrite = await companyAccess.CanWriteAsync(attachment.CompanyId, cancellationToken); if (!canWrite) { return Forbid(); } if (string.IsNullOrWhiteSpace(reason)) { return BadRequest(new { error = "REASON_REQUIRED", message = "A reason for deletion is required (Bogføringsloven § 6)" }); } try { var command = new DeleteAttachmentCommand( new AttachmentId(id), userId, reason); await commandBus.PublishAsync(command, cancellationToken); // Also delete the physical file await fileStorage.DeleteAsync(attachment.StoragePath, cancellationToken); return Ok(new { success = true, message = "Attachment deleted" }); } catch (DomainException ex) { return BadRequest(new { error = ex.Code, message = ex.MessageDanish }); } } }