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>
142 lines
4.7 KiB
C#
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"
|
|
};
|
|
}
|
|
}
|