using Lagerbestand.Models; using Microsoft.Extensions.Logging; using System.Text.Json; namespace Lagerbestand.Services; /// /// File storage service for disk/NFS-based file operations /// public class FileStorageService : IFileStorageService { private readonly ISiteSettingsService _siteSettingsService; private readonly ILogger _logger; private const string SettingsKey = "FileStorage"; public FileStorageService(ISiteSettingsService siteSettingsService, ILogger logger) { _siteSettingsService = siteSettingsService; _logger = logger; } public async Task GetSettingsAsync() { try { var json = await _siteSettingsService.GetValueAsync(SettingsKey); if (string.IsNullOrEmpty(json)) { return new FileStorageSettings(); } return JsonSerializer.Deserialize(json) ?? new FileStorageSettings(); } catch (Exception ex) { _logger.LogError(ex, "Error loading file storage settings"); return new FileStorageSettings(); } } public async Task SaveSettingsAsync(FileStorageSettings settings) { var json = JsonSerializer.Serialize(settings); await _siteSettingsService.SetValueAsync(SettingsKey, json); _logger.LogInformation("File storage settings saved. Path: {BasePath}, Enabled: {IsEnabled}", settings.BasePath, settings.IsEnabled); } public Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path) { if (string.IsNullOrWhiteSpace(path)) { return Task.FromResult((false, (string?)"Der Pfad darf nicht leer sein.")); } try { // Check if directory exists or can be created if (!Directory.Exists(path)) { try { Directory.CreateDirectory(path); _logger.LogInformation("Created storage directory: {Path}", path); } catch (Exception ex) { return Task.FromResult((false, (string?)$"Verzeichnis kann nicht erstellt werden: {ex.Message}")); } } // Test write access var testFile = Path.Combine(path, $".write_test_{Guid.NewGuid():N}"); try { File.WriteAllText(testFile, "test"); File.Delete(testFile); } catch (Exception ex) { return Task.FromResult((false, (string?)$"Keine Schreibrechte: {ex.Message}")); } return Task.FromResult((true, (string?)null)); } catch (Exception ex) { return Task.FromResult((false, (string?)$"Fehler: {ex.Message}")); } } public async Task SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments") { var settings = await GetSettingsAsync(); if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) { throw new InvalidOperationException("Dateispeicher ist nicht konfiguriert."); } // Create unique filename to avoid collisions var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); var uniqueId = Guid.NewGuid().ToString("N")[..8]; var safeFileName = SanitizeFileName(fileName); var extension = Path.GetExtension(safeFileName); var nameWithoutExt = Path.GetFileNameWithoutExtension(safeFileName); var finalFileName = $"{nameWithoutExt}_{timestamp}_{uniqueId}{extension}"; // Create subdirectory path with year/month structure var datePath = DateTime.UtcNow.ToString("yyyy/MM"); var relativePath = Path.Combine(subDirectory, datePath, finalFileName); var fullPath = Path.Combine(settings.BasePath, relativePath); // Ensure directory exists var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } await File.WriteAllBytesAsync(fullPath, content); _logger.LogInformation("File saved: {RelativePath} ({Size} bytes)", relativePath, content.Length); return relativePath; } public async Task ReadFileAsync(string relativePath) { var settings = await GetSettingsAsync(); if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) { return null; } var fullPath = ResolveSafePath(settings.BasePath, relativePath); if (fullPath == null || !File.Exists(fullPath)) { _logger.LogWarning("File not found: {RelativePath}", relativePath); return null; } return await File.ReadAllBytesAsync(fullPath); } public async Task DeleteFileAsync(string relativePath) { var settings = await GetSettingsAsync(); if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) { return false; } var fullPath = ResolveSafePath(settings.BasePath, relativePath); if (fullPath == null) { return false; } if (!File.Exists(fullPath)) { return true; // Already deleted } try { File.Delete(fullPath); _logger.LogInformation("File deleted: {RelativePath}", relativePath); return true; } catch (Exception ex) { _logger.LogError(ex, "Error deleting file: {RelativePath}", relativePath); return false; } } public async Task FileExistsAsync(string relativePath) { var settings = await GetSettingsAsync(); if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) { return false; } var fullPath = ResolveSafePath(settings.BasePath, relativePath); return await Task.FromResult(fullPath != null && File.Exists(fullPath)); } public async Task GetFileSizeAsync(string relativePath) { var settings = await GetSettingsAsync(); if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath)) { return 0; } var fullPath = ResolveSafePath(settings.BasePath, relativePath); if (fullPath == null || !File.Exists(fullPath)) { return 0; } var fileInfo = new FileInfo(fullPath); return await Task.FromResult(fileInfo.Length); } /// /// Resolves a relative path against the base path and blocks path traversal attempts. /// Returns null if the resolved path escapes the base directory. /// private string? ResolveSafePath(string basePath, string relativePath) { try { var fullBasePath = Path.GetFullPath(basePath); var fullPath = Path.GetFullPath(Path.Combine(fullBasePath, relativePath)); if (!fullPath.StartsWith(fullBasePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("Path traversal attempt blocked: {RelativePath}", relativePath); return null; } return fullPath; } catch (Exception ex) { _logger.LogWarning(ex, "Invalid path rejected: {RelativePath}", relativePath); return null; } } private static string SanitizeFileName(string fileName) { var invalidChars = Path.GetInvalidFileNameChars(); var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); // Limit length if (sanitized.Length > 200) { var ext = Path.GetExtension(sanitized); var name = Path.GetFileNameWithoutExtension(sanitized); sanitized = name[..(200 - ext.Length)] + ext; } return sanitized; } }