books/backend/Books.Api/Infrastructure/FileStorage/LocalFileStorageService.cs
Nicolaj Hartmann 8096a19081 Audit v4: VAT calc, SAF-T compliance, security hardening, frontend quality
Backend (17 files):
- VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY),
  IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue
- SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback,
  credit note auto-numbering (§52)
- Security: BankingController CSRF state token + company auth check,
  attachment canonical path traversal check, discount 0-100% validation,
  deactivated product/customer update guard
- Quality: redact bank API logs, remove dead code (VatCalcService,
  PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding

Frontend (15 files):
- Fix double "kr." in AmountText and Dashboard Statistic components
- Fix UserSettings Switch defaultChecked desync with Form state
- Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank)
- Correct SKAT VAT deadline calculation per period type
- Add half-yearly/yearly VAT period options
- Guard console.error with import.meta.env.DEV
- Use shared formatDate in BankConnectionsTab
- Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union
- Migrate S25→U25, K25→I25 across all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:38:52 +01:00

142 lines
4.7 KiB
C#

namespace Books.Api.Infrastructure.FileStorage;
/// <summary>
/// Local file system storage implementation.
/// Suitable for development and small deployments.
/// For production, consider Azure Blob Storage or AWS S3.
/// </summary>
public class LocalFileStorageService : IFileStorageService
{
private readonly string _basePath;
private readonly string _baseUrl;
public LocalFileStorageService(IConfiguration configuration)
{
_basePath = configuration["FileStorage:LocalPath"]
?? Path.Combine(Directory.GetCurrentDirectory(), "uploads");
_baseUrl = configuration["FileStorage:BaseUrl"]
?? "/api/attachments";
// Ensure base directory exists
if (!Directory.Exists(_basePath))
{
Directory.CreateDirectory(_basePath);
}
}
public async Task<StorageResult> StoreAsync(
string companyId,
string fileName,
string contentType,
Stream content,
CancellationToken cancellationToken = default)
{
// Create company directory
var companyPath = Path.Combine(_basePath, companyId);
if (!Directory.Exists(companyPath))
{
Directory.CreateDirectory(companyPath);
}
// Generate unique filename to prevent collisions
var extension = Path.GetExtension(fileName);
var sanitizedName = SanitizeFileName(Path.GetFileNameWithoutExtension(fileName));
var uniqueId = Guid.NewGuid().ToString("N")[..8];
var storedFileName = $"{sanitizedName}_{uniqueId}{extension}";
var filePath = Path.Combine(companyPath, storedFileName);
// Store file
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
await content.CopyToAsync(fileStream, cancellationToken);
// Storage path is relative to base (company/filename)
var storagePath = $"{companyId}/{storedFileName}";
return new StorageResult
{
StoragePath = storagePath,
StoredFileName = storedFileName,
FileSize = fileStream.Length
};
}
public Task<FileResult> GetAsync(string storagePath, CancellationToken cancellationToken = default)
{
var filePath = Path.Combine(_basePath, storagePath);
if (!File.Exists(filePath))
return Task.FromResult<FileResult?>(null)!;
var fileInfo = new FileInfo(filePath);
var contentType = GetContentType(fileInfo.Extension);
// Open for reading (caller is responsible for disposing)
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return Task.FromResult(new FileResult
{
Content = stream,
ContentType = contentType,
FileName = fileInfo.Name,
FileSize = fileInfo.Length
});
}
public Task DeleteAsync(string storagePath, CancellationToken cancellationToken = default)
{
var filePath = Path.Combine(_basePath, storagePath);
if (File.Exists(filePath))
{
File.Delete(filePath);
}
return Task.CompletedTask;
}
public string GetBasePath() => _basePath;
public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null)
{
// For local storage, return API endpoint URL
// The actual download is handled by a controller
// Encode each path segment individually to avoid double-encoding path separators
var encodedPath = string.Join("/", storagePath.Split('/').Select(Uri.EscapeDataString));
return $"{_baseUrl}/{encodedPath}";
}
private static string SanitizeFileName(string fileName)
{
// Remove invalid characters
var invalid = Path.GetInvalidFileNameChars();
var sanitized = string.Join("", fileName.Split(invalid));
// Limit length
if (sanitized.Length > 50)
sanitized = sanitized[..50];
// Default if empty
if (string.IsNullOrWhiteSpace(sanitized))
sanitized = "attachment";
return sanitized;
}
private static string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream"
};
}
}