PlotDirector/PlotLine/Services/UploadStorageService.cs

143 lines
4.9 KiB
C#

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<StorageSettings> 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<string> 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;
}
}
}