255 lines
8.2 KiB
C#
255 lines
8.2 KiB
C#
using Lagerbestand.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Text.Json;
|
|
|
|
namespace Lagerbestand.Services;
|
|
|
|
/// <summary>
|
|
/// File storage service for disk/NFS-based file operations
|
|
/// </summary>
|
|
public class FileStorageService : IFileStorageService
|
|
{
|
|
private readonly ISiteSettingsService _siteSettingsService;
|
|
private readonly ILogger<FileStorageService> _logger;
|
|
private const string SettingsKey = "FileStorage";
|
|
|
|
public FileStorageService(ISiteSettingsService siteSettingsService, ILogger<FileStorageService> logger)
|
|
{
|
|
_siteSettingsService = siteSettingsService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<FileStorageSettings> GetSettingsAsync()
|
|
{
|
|
try
|
|
{
|
|
var json = await _siteSettingsService.GetValueAsync(SettingsKey);
|
|
if (string.IsNullOrEmpty(json))
|
|
{
|
|
return new FileStorageSettings();
|
|
}
|
|
|
|
return JsonSerializer.Deserialize<FileStorageSettings>(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<string> 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<byte[]?> 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<bool> 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<bool> 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<long> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a relative path against the base path and blocks path traversal attempts.
|
|
/// Returns null if the resolved path escapes the base directory.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|