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" });
}
// Look up the attachment to verify company access
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
if (attachment == null)
{
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
}
// Verify the user has access to the company that owns this attachment
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
if (access == null)
{
return Forbid();
}
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 });
}
}
}