Implement backend support for storing user uploads outside wwwroot/uploads
This commit is contained in:
parent
6ab9de8795
commit
2b145731e3
@ -14,7 +14,7 @@ public sealed class ScenesController(
|
|||||||
ILocationService locations,
|
ILocationService locations,
|
||||||
IWriterWorkspaceService writerWorkspace,
|
IWriterWorkspaceService writerWorkspace,
|
||||||
IStorageService storage,
|
IStorageService storage,
|
||||||
IWebHostEnvironment environment) : Controller
|
IUploadStorageService uploadStorage) : Controller
|
||||||
{
|
{
|
||||||
private const long MaxAttachmentBytes = 25 * 1024 * 1024;
|
private const long MaxAttachmentBytes = 25 * 1024 * 1024;
|
||||||
|
|
||||||
@ -456,7 +456,7 @@ public sealed class ScenesController(
|
|||||||
&& !string.Equals(originalFilePath, model.FilePath, StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(originalFilePath, model.FilePath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await storage.MarkDeletedByStoragePathAsync(originalFilePath);
|
await storage.MarkDeletedByStoragePathAsync(originalFilePath);
|
||||||
DeleteUploadedAttachment(originalFilePath);
|
uploadStorage.TryDeleteFile(originalFilePath, "uploads/scenes");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -469,7 +469,7 @@ public sealed class ScenesController(
|
|||||||
{
|
{
|
||||||
await writerWorkspace.DeleteAttachmentAsync(id);
|
await writerWorkspace.DeleteAttachmentAsync(id);
|
||||||
await storage.MarkDeletedByStoragePathAsync(filePath);
|
await storage.MarkDeletedByStoragePathAsync(filePath);
|
||||||
DeleteUploadedAttachment(filePath);
|
uploadStorage.TryDeleteFile(filePath, "uploads/scenes");
|
||||||
return RedirectForScene(sceneId, projectId, bookId);
|
return RedirectForScene(sceneId, projectId, bookId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,8 +518,7 @@ public sealed class ScenesController(
|
|||||||
|
|
||||||
private async Task<string> SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile)
|
private async Task<string> SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile)
|
||||||
{
|
{
|
||||||
var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString());
|
var uploadRoot = uploadStorage.GetDirectory("scenes", sceneId.ToString());
|
||||||
Directory.CreateDirectory(uploadRoot);
|
|
||||||
|
|
||||||
var originalName = Path.GetFileName(uploadedFile.FileName);
|
var originalName = Path.GetFileName(uploadedFile.FileName);
|
||||||
var safeName = MakeSafeFileName(originalName);
|
var safeName = MakeSafeFileName(originalName);
|
||||||
@ -529,7 +528,7 @@ public sealed class ScenesController(
|
|||||||
await using var stream = System.IO.File.Create(absolutePath);
|
await using var stream = System.IO.File.Create(absolutePath);
|
||||||
await uploadedFile.CopyToAsync(stream);
|
await uploadedFile.CopyToAsync(stream);
|
||||||
|
|
||||||
return $"/uploads/scenes/{sceneId}/{storedName}";
|
return uploadStorage.GetPublicPath("scenes", sceneId.ToString(), storedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string MakeSafeFileName(string fileName)
|
private static string MakeSafeFileName(string fileName)
|
||||||
@ -544,21 +543,4 @@ public sealed class ScenesController(
|
|||||||
return name.Length > 120 ? name[^120..] : name;
|
return name.Length > 120 ? name[^120..] : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DeleteUploadedAttachment(string? filePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(filePath) || !filePath.StartsWith("/uploads/scenes/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads"));
|
|
||||||
var relativePath = filePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
|
|
||||||
var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, relativePath));
|
|
||||||
if (!absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(absolutePath))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
System.IO.File.Delete(absolutePath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
13
PlotLine/Docs/Architecture/DeploymentConfiguration.md
Normal file
13
PlotLine/Docs/Architecture/DeploymentConfiguration.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Deployment Configuration
|
||||||
|
|
||||||
|
## Uploaded files
|
||||||
|
|
||||||
|
By default, uploaded files are stored in `wwwroot/uploads` for local development.
|
||||||
|
|
||||||
|
Production deployments should configure an external upload folder so application releases can be swapped without removing user files. On Linux, set the environment variable:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Storage__UploadsRoot=/data/plotdirector/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
The application creates the configured folder if it does not already exist. Public upload URLs remain under `/uploads/...`.
|
||||||
@ -5,6 +5,7 @@ using PlotLine.Models;
|
|||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
namespace PlotLine;
|
namespace PlotLine;
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ public class Program
|
|||||||
});
|
});
|
||||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||||
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
||||||
|
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
|
||||||
builder.Services.AddDataProtection()
|
builder.Services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
@ -134,6 +136,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
|
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
|
||||||
builder.Services.AddScoped<IWritingScheduleService, WritingScheduleService>();
|
builder.Services.AddScoped<IWritingScheduleService, WritingScheduleService>();
|
||||||
builder.Services.AddScoped<IStorageService, StorageService>();
|
builder.Services.AddScoped<IStorageService, StorageService>();
|
||||||
|
builder.Services.AddSingleton<IUploadStorageService, UploadStorageService>();
|
||||||
builder.Services.AddScoped<IExportService, ExportService>();
|
builder.Services.AddScoped<IExportService, ExportService>();
|
||||||
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
|
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
|
||||||
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
|
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
|
||||||
@ -168,6 +171,12 @@ public class Program
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
var uploadStorage = app.Services.GetRequiredService<IUploadStorageService>();
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(uploadStorage.UploadsRootPath),
|
||||||
|
RequestPath = "/uploads"
|
||||||
|
});
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
app.UseSession();
|
app.UseSession();
|
||||||
|
|||||||
@ -14,7 +14,7 @@ public interface IBookCoverService
|
|||||||
public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null);
|
public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null);
|
||||||
|
|
||||||
public sealed class BookCoverService(
|
public sealed class BookCoverService(
|
||||||
IWebHostEnvironment environment,
|
IUploadStorageService uploadStorage,
|
||||||
IProjectCollaborationRepository collaboration,
|
IProjectCollaborationRepository collaboration,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
IUserFileRepository userFiles,
|
IUserFileRepository userFiles,
|
||||||
@ -100,16 +100,15 @@ public sealed class BookCoverService(
|
|||||||
return new(false, "Cover image could not be read as a valid image.");
|
return new(false, "Cover image could not be read as a valid image.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "books", book.BookID.ToString());
|
var uploadRoot = uploadStorage.GetDirectory("books", book.BookID.ToString());
|
||||||
Directory.CreateDirectory(uploadRoot);
|
|
||||||
|
|
||||||
var version = Guid.NewGuid().ToString("N");
|
var version = Guid.NewGuid().ToString("N");
|
||||||
var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp");
|
var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp");
|
||||||
var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp");
|
var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp");
|
||||||
var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName);
|
var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName);
|
||||||
var standardPath = Path.Combine(uploadRoot, StandardFileName);
|
var standardPath = Path.Combine(uploadRoot, StandardFileName);
|
||||||
var thumbnailStoragePath = $"/uploads/books/{book.BookID}/{ThumbnailFileName}";
|
var thumbnailStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), ThumbnailFileName);
|
||||||
var standardStoragePath = $"/uploads/books/{book.BookID}/{StandardFileName}";
|
var standardStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), StandardFileName);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -222,31 +221,7 @@ public sealed class BookCoverService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteUploadFile(oldStoragePath);
|
uploadStorage.TryDeleteFile(oldStoragePath, "uploads/books");
|
||||||
}
|
|
||||||
|
|
||||||
private void DeleteUploadFile(string storagePath)
|
|
||||||
{
|
|
||||||
var absolutePath = ResolveUploadPath(storagePath);
|
|
||||||
if (absolutePath is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TryDeleteFile(absolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? ResolveUploadPath(string storagePath)
|
|
||||||
{
|
|
||||||
var normalised = storagePath.Replace('\\', '/').TrimStart('/');
|
|
||||||
if (!normalised.StartsWith("uploads/books/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads"));
|
|
||||||
var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar)));
|
|
||||||
return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ReplaceFile(string tempPath, string finalPath)
|
private static void ReplaceFile(string tempPath, string finalPath)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ public interface IHardDeleteFileCleanupService
|
|||||||
public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed);
|
public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed);
|
||||||
|
|
||||||
public sealed class HardDeleteFileCleanupService(
|
public sealed class HardDeleteFileCleanupService(
|
||||||
IWebHostEnvironment environment,
|
IUploadStorageService uploadStorage,
|
||||||
ILogger<HardDeleteFileCleanupService> logger) : IHardDeleteFileCleanupService
|
ILogger<HardDeleteFileCleanupService> logger) : IHardDeleteFileCleanupService
|
||||||
{
|
{
|
||||||
public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList<string> storagePaths)
|
public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList<string> storagePaths)
|
||||||
@ -20,7 +20,7 @@ public sealed class HardDeleteFileCleanupService(
|
|||||||
|
|
||||||
foreach (var storagePath in storagePaths)
|
foreach (var storagePath in storagePaths)
|
||||||
{
|
{
|
||||||
var absolutePath = ResolveUploadPath(storagePath);
|
var absolutePath = uploadStorage.TryResolvePublicPath(storagePath);
|
||||||
if (absolutePath is null)
|
if (absolutePath is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Hard delete skipped a non-local upload path during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
logger.LogWarning("Hard delete skipped a non-local upload path during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||||
@ -61,32 +61,17 @@ public sealed class HardDeleteFileCleanupService(
|
|||||||
return new HardDeleteFileCleanupResult(deleted, missing, failed);
|
return new HardDeleteFileCleanupResult(deleted, missing, failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? ResolveUploadPath(string storagePath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(storagePath)
|
|
||||||
|| storagePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| storagePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalised = storagePath.Replace('\\', '/').TrimStart('/');
|
|
||||||
if (!normalised.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads"));
|
|
||||||
var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar)));
|
|
||||||
return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryDeleteEmptyUploadDirectory(string operationName, int ownerId, string directory)
|
private void TryDeleteEmptyUploadDirectory(string operationName, int ownerId, string directory)
|
||||||
{
|
{
|
||||||
var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads"));
|
var uploadsRoot = Path.EndsInDirectorySeparator(uploadStorage.UploadsRootPath)
|
||||||
|
? uploadStorage.UploadsRootPath
|
||||||
|
: uploadStorage.UploadsRootPath + Path.DirectorySeparatorChar;
|
||||||
|
var comparison = OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal;
|
||||||
var fullDirectory = Path.GetFullPath(directory);
|
var fullDirectory = Path.GetFullPath(directory);
|
||||||
if (!fullDirectory.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase)
|
if (!fullDirectory.StartsWith(uploadsRoot, comparison)
|
||||||
|| string.Equals(fullDirectory, uploadsRoot, StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(fullDirectory, uploadStorage.UploadsRootPath, comparison))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
142
PlotLine/Services/UploadStorageService.cs
Normal file
142
PlotLine/Services/UploadStorageService.cs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,9 @@
|
|||||||
"SecretKey": "",
|
"SecretKey": "",
|
||||||
"WebhookSecret": ""
|
"WebhookSecret": ""
|
||||||
},
|
},
|
||||||
|
"Storage": {
|
||||||
|
"UploadsRoot": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user