books/backend/Books.Api/Controllers/AttachmentController.cs

284 lines
9.6 KiB
C#
Raw Normal View History

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;
/// <summary>
/// REST API for attachment (bilag) file operations.
/// GraphQL doesn't handle file uploads well, so we use REST for this.
/// </summary>
[ApiController]
[Route("api/attachments")]
[Authorize]
public class AttachmentController(
ICommandBus commandBus,
IAttachmentRepository attachmentRepository,
IFileStorageService fileStorage,
ICompanyAccessService companyAccess,
ILogger<AttachmentController> logger) : ControllerBase
{
/// <summary>
/// Upload one or more attachments for a company.
/// </summary>
/// <param name="companyId">Company ID</param>
/// <param name="draftId">Optional draft ID to link attachments to</param>
/// <param name="files">Files to upload</param>
[HttpPost("upload")]
[RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total
public async Task<IActionResult> Upload(
[FromQuery] string companyId,
[FromQuery] string? draftId,
[FromForm] List<IFormFile> 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<object>();
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 });
}
/// <summary>
/// Get attachments for a draft.
/// </summary>
[HttpGet("draft/{draftId}")]
public async Task<IActionResult> GetByDraft(string draftId, CancellationToken cancellationToken)
{
var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken);
if (attachments.Count == 0)
{
return Ok(new { attachments = Array.Empty<object>() });
}
// 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 });
}
/// <summary>
/// Download an attachment by storage path.
/// </summary>
[HttpGet("{*storagePath}")]
public async Task<IActionResult> 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" });
}
Audit v2: fix security, data integrity, compliance, bugs, encoding, UX Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:18:19 +01:00
// 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);
}
/// <summary>
/// Delete an attachment.
/// Note: Per Bogføringsloven § 6, attachments linked to transactions
/// cannot be deleted within the 5-year retention period.
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> 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 });
}
}
}