From 2b145731e36d02b66ba4a7dfc1f7b23e28b336f5 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 15 Jun 2026 19:14:09 +0100 Subject: [PATCH] Implement backend support for storing user uploads outside wwwroot/uploads --- PlotLine/Controllers/ScenesController.cs | 28 +--- .../Architecture/DeploymentConfiguration.md | 13 ++ PlotLine/Program.cs | 9 ++ PlotLine/Services/BookCoverService.cs | 35 +---- .../Services/HardDeleteFileCleanupService.cs | 35 ++--- PlotLine/Services/UploadStorageService.cs | 142 ++++++++++++++++++ PlotLine/appsettings.json | 3 + 7 files changed, 187 insertions(+), 78 deletions(-) create mode 100644 PlotLine/Docs/Architecture/DeploymentConfiguration.md create mode 100644 PlotLine/Services/UploadStorageService.cs diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index 0908cdb..eafc316 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -14,7 +14,7 @@ public sealed class ScenesController( ILocationService locations, IWriterWorkspaceService writerWorkspace, IStorageService storage, - IWebHostEnvironment environment) : Controller + IUploadStorageService uploadStorage) : Controller { private const long MaxAttachmentBytes = 25 * 1024 * 1024; @@ -456,7 +456,7 @@ public sealed class ScenesController( && !string.Equals(originalFilePath, model.FilePath, StringComparison.OrdinalIgnoreCase)) { await storage.MarkDeletedByStoragePathAsync(originalFilePath); - DeleteUploadedAttachment(originalFilePath); + uploadStorage.TryDeleteFile(originalFilePath, "uploads/scenes"); } } @@ -469,7 +469,7 @@ public sealed class ScenesController( { await writerWorkspace.DeleteAttachmentAsync(id); await storage.MarkDeletedByStoragePathAsync(filePath); - DeleteUploadedAttachment(filePath); + uploadStorage.TryDeleteFile(filePath, "uploads/scenes"); return RedirectForScene(sceneId, projectId, bookId); } @@ -518,8 +518,7 @@ public sealed class ScenesController( private async Task SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile) { - var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString()); - Directory.CreateDirectory(uploadRoot); + var uploadRoot = uploadStorage.GetDirectory("scenes", sceneId.ToString()); var originalName = Path.GetFileName(uploadedFile.FileName); var safeName = MakeSafeFileName(originalName); @@ -529,7 +528,7 @@ public sealed class ScenesController( await using var stream = System.IO.File.Create(absolutePath); await uploadedFile.CopyToAsync(stream); - return $"/uploads/scenes/{sceneId}/{storedName}"; + return uploadStorage.GetPublicPath("scenes", sceneId.ToString(), storedName); } private static string MakeSafeFileName(string fileName) @@ -544,21 +543,4 @@ public sealed class ScenesController( 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); - } } diff --git a/PlotLine/Docs/Architecture/DeploymentConfiguration.md b/PlotLine/Docs/Architecture/DeploymentConfiguration.md new file mode 100644 index 0000000..0619c63 --- /dev/null +++ b/PlotLine/Docs/Architecture/DeploymentConfiguration.md @@ -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/...`. diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 76956e4..0c04225 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -5,6 +5,7 @@ using PlotLine.Models; using PlotLine.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.FileProviders; namespace PlotLine; @@ -33,6 +34,7 @@ public class Program }); builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); builder.Services.Configure(builder.Configuration.GetSection("Stripe")); + builder.Services.Configure(builder.Configuration.GetSection("Storage")); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -134,6 +136,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -168,6 +171,12 @@ public class Program app.UseHttpsRedirection(); app.UseStaticFiles(); + var uploadStorage = app.Services.GetRequiredService(); + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(uploadStorage.UploadsRootPath), + RequestPath = "/uploads" + }); app.UseRouting(); app.UseSession(); diff --git a/PlotLine/Services/BookCoverService.cs b/PlotLine/Services/BookCoverService.cs index 6b7fba7..1595dc4 100644 --- a/PlotLine/Services/BookCoverService.cs +++ b/PlotLine/Services/BookCoverService.cs @@ -14,7 +14,7 @@ public interface IBookCoverService public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null); public sealed class BookCoverService( - IWebHostEnvironment environment, + IUploadStorageService uploadStorage, IProjectCollaborationRepository collaboration, ISubscriptionService subscriptions, IUserFileRepository userFiles, @@ -100,16 +100,15 @@ public sealed class BookCoverService( return new(false, "Cover image could not be read as a valid image."); } - var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "books", book.BookID.ToString()); - Directory.CreateDirectory(uploadRoot); + var uploadRoot = uploadStorage.GetDirectory("books", book.BookID.ToString()); var version = Guid.NewGuid().ToString("N"); var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp"); var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp"); var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName); var standardPath = Path.Combine(uploadRoot, StandardFileName); - var thumbnailStoragePath = $"/uploads/books/{book.BookID}/{ThumbnailFileName}"; - var standardStoragePath = $"/uploads/books/{book.BookID}/{StandardFileName}"; + var thumbnailStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), ThumbnailFileName); + var standardStoragePath = uploadStorage.GetPublicPath("books", book.BookID.ToString(), StandardFileName); try { @@ -222,31 +221,7 @@ public sealed class BookCoverService( return; } - DeleteUploadFile(oldStoragePath); - } - - 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; + uploadStorage.TryDeleteFile(oldStoragePath, "uploads/books"); } private static void ReplaceFile(string tempPath, string finalPath) diff --git a/PlotLine/Services/HardDeleteFileCleanupService.cs b/PlotLine/Services/HardDeleteFileCleanupService.cs index 24996e9..b506b3a 100644 --- a/PlotLine/Services/HardDeleteFileCleanupService.cs +++ b/PlotLine/Services/HardDeleteFileCleanupService.cs @@ -8,7 +8,7 @@ public interface IHardDeleteFileCleanupService public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed); public sealed class HardDeleteFileCleanupService( - IWebHostEnvironment environment, + IUploadStorageService uploadStorage, ILogger logger) : IHardDeleteFileCleanupService { public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList storagePaths) @@ -20,7 +20,7 @@ public sealed class HardDeleteFileCleanupService( foreach (var storagePath in storagePaths) { - var absolutePath = ResolveUploadPath(storagePath); + var absolutePath = uploadStorage.TryResolvePublicPath(storagePath); if (absolutePath is null) { 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); } - 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) { - 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); - if (!fullDirectory.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) - || string.Equals(fullDirectory, uploadsRoot, StringComparison.OrdinalIgnoreCase)) + if (!fullDirectory.StartsWith(uploadsRoot, comparison) + || string.Equals(fullDirectory, uploadStorage.UploadsRootPath, comparison)) { return; } diff --git a/PlotLine/Services/UploadStorageService.cs b/PlotLine/Services/UploadStorageService.cs new file mode 100644 index 0000000..257c180 --- /dev/null +++ b/PlotLine/Services/UploadStorageService.cs @@ -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 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; + } + } +} diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index 332b876..e485f0e 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -16,6 +16,9 @@ "SecretKey": "", "WebhookSecret": "" }, + "Storage": { + "UploadsRoot": "" + }, "Logging": { "LogLevel": { "Default": "Information",