Implement backend support for storing user uploads outside wwwroot/uploads

This commit is contained in:
Nick Beckley 2026-06-15 19:14:09 +01:00
parent 6ab9de8795
commit 2b145731e3
7 changed files with 187 additions and 78 deletions

View File

@ -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<string> 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);
}
}

View 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/...`.

View File

@ -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<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<StorageSettings>(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<IWriterWorkspaceService, WriterWorkspaceService>();
builder.Services.AddScoped<IWritingScheduleService, WritingScheduleService>();
builder.Services.AddScoped<IStorageService, StorageService>();
builder.Services.AddSingleton<IUploadStorageService, UploadStorageService>();
builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
@ -168,6 +171,12 @@ public class Program
app.UseHttpsRedirection();
app.UseStaticFiles();
var uploadStorage = app.Services.GetRequiredService<IUploadStorageService>();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploadStorage.UploadsRootPath),
RequestPath = "/uploads"
});
app.UseRouting();
app.UseSession();

View File

@ -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)

View File

@ -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<HardDeleteFileCleanupService> logger) : IHardDeleteFileCleanupService
{
public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList<string> 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;
}

View 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;
}
}
}

View File

@ -16,6 +16,9 @@
"SecretKey": "",
"WebhookSecret": ""
},
"Storage": {
"UploadsRoot": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",