using Microsoft.Extensions.Options; namespace PlotLine.Services; public sealed class StorageSettings { public string? UploadsRoot { get; set; } } public interface IUploadStorageService { string UploadsRootPath { get; } string GetDirectory(params string[] relativeSegments); string GetFilePath(params string[] relativeSegments); string GetPublicPath(params string[] relativeSegments); string? TryResolvePublicPath(string? storagePath, string? requiredPrefix = null); bool TryDeleteFile(string? storagePath, string? requiredPrefix = null); } public sealed class UploadStorageService : IUploadStorageService { private const string PublicRoot = "uploads"; private readonly StringComparison pathComparison; public UploadStorageService(IOptions options, IWebHostEnvironment environment) { pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; var configuredRoot = options.Value.UploadsRoot; UploadsRootPath = string.IsNullOrWhiteSpace(configuredRoot) ? Path.Combine(environment.WebRootPath, PublicRoot) : GetConfiguredRoot(environment.ContentRootPath, configuredRoot); UploadsRootPath = Path.GetFullPath(UploadsRootPath); Directory.CreateDirectory(UploadsRootPath); } public string UploadsRootPath { get; } public string GetDirectory(params string[] relativeSegments) { var directory = GetPhysicalPath(relativeSegments); Directory.CreateDirectory(directory); return directory; } public string GetFilePath(params string[] relativeSegments) => GetPhysicalPath(relativeSegments); public string GetPublicPath(params string[] relativeSegments) { var safeSegments = relativeSegments.SelectMany(SplitSegments).ToArray(); return "/" + string.Join('/', new[] { PublicRoot }.Concat(safeSegments)); } public string? TryResolvePublicPath(string? storagePath, string? requiredPrefix = null) { if (string.IsNullOrWhiteSpace(storagePath) || storagePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || storagePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return null; } var normalised = storagePath.Replace('\\', '/').TrimStart('/'); if (!normalised.StartsWith(PublicRoot + "/", StringComparison.OrdinalIgnoreCase)) { return null; } if (!string.IsNullOrWhiteSpace(requiredPrefix)) { var normalisedPrefix = requiredPrefix.Replace('\\', '/').Trim('/'); if (!normalised.StartsWith(normalisedPrefix + "/", StringComparison.OrdinalIgnoreCase)) { return null; } } try { var relativePath = normalised[(PublicRoot.Length + 1)..]; return GetPhysicalPath(SplitSegments(relativePath).ToArray()); } catch (InvalidOperationException) { return null; } } public bool TryDeleteFile(string? storagePath, string? requiredPrefix = null) { var absolutePath = TryResolvePublicPath(storagePath, requiredPrefix); if (absolutePath is null || !File.Exists(absolutePath)) { return false; } File.Delete(absolutePath); return true; } private string GetPhysicalPath(params string[] relativeSegments) { var safeSegments = relativeSegments.SelectMany(SplitSegments).ToArray(); var combined = safeSegments.Aggregate(UploadsRootPath, Path.Combine); var fullPath = Path.GetFullPath(combined); return IsWithinUploadsRoot(fullPath) ? fullPath : throw new InvalidOperationException("Upload path escaped the configured uploads root."); } private bool IsWithinUploadsRoot(string path) { var root = Path.EndsInDirectorySeparator(UploadsRootPath) ? UploadsRootPath : UploadsRootPath + Path.DirectorySeparatorChar; return path.StartsWith(root, pathComparison) || string.Equals(path, UploadsRootPath, pathComparison); } private static string GetConfiguredRoot(string contentRootPath, string configuredRoot) { var expandedRoot = Environment.ExpandEnvironmentVariables(configuredRoot.Trim()); return Path.IsPathRooted(expandedRoot) ? expandedRoot : Path.Combine(contentRootPath, expandedRoot); } private static IEnumerable SplitSegments(string value) { foreach (var segment in value.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries)) { if (segment is "." or ".." || Path.IsPathRooted(segment) || segment.IndexOfAny(Path.GetInvalidPathChars()) >= 0) { throw new InvalidOperationException("Invalid upload path segment."); } yield return segment; } } }