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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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": "",
|
||||
"WebhookSecret": ""
|
||||
},
|
||||
"Storage": {
|
||||
"UploadsRoot": ""
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user