143 lines
4.9 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|