Skip to content

Commit 108220b

Browse files
authored
Don't lose media files when there's an S&R rollback (#1876)
* refactor saving and deleting media files into the MediaFileService.cs * commit files to the hg repo when they are saved to the media server * write some more tests for updating existing files and throwing for files which are too big * don't overwrite the old file if the new file is too big * ensure repeat uploads aren't exactly the same which will fail when file contents are the same
1 parent 47f8375 commit 108220b

File tree

12 files changed

+363
-180
lines changed

12 files changed

+363
-180
lines changed

backend/FwHeadless/Controllers/MediaFileController.cs

Lines changed: 34 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.Net.Http.Headers;
99
using System.Globalization;
1010
using FwHeadless.Media;
11+
using LexCore.Exceptions;
1112
using MimeMapping;
1213

1314
namespace FwHeadless.Controllers;
@@ -68,16 +69,12 @@ public static async Task<Results<PhysicalFileHttpResult, NotFound>> GetFile(
6869
public static async Task<Results<Ok, NotFound>> DeleteFile(
6970
Guid fileId,
7071
IOptions<FwHeadlessConfig> config,
72+
MediaFileService mediaFileService,
7173
LexBoxDbContext lexBoxDb)
7274
{
73-
var mediaFile = await lexBoxDb.Files.FindAsync(fileId);
75+
var mediaFile = await mediaFileService.FindMediaFileAsync(fileId);
7476
if (mediaFile is null) return TypedResults.NotFound();
75-
var projectId = mediaFile.ProjectId;
76-
var project = await lexBoxDb.Projects.FindAsync(projectId);
77-
if (project is null) return TypedResults.NotFound();
78-
var projectFolder = config.Value.GetFwDataProject(project.Code, projectId).ProjectFolder;
79-
SafeDeleteMediaFile(mediaFile, projectFolder, lexBoxDb);
80-
await lexBoxDb.SaveChangesAsync();
77+
await mediaFileService.DeleteMediaFile(mediaFile);
8178
return TypedResults.Ok();
8279
}
8380

@@ -90,9 +87,10 @@ public static async Task<Results<Ok<PostFileResult>, Created<PostFileResult>, No
9087
[FromForm] string? linkedFilesSubfolderOverride,
9188
HttpContext httpContext,
9289
IOptions<FwHeadlessConfig> config,
90+
MediaFileService mediaFileService,
9391
LexBoxDbContext lexBoxDb)
9492
{
95-
var result = await HandleFileUpload(fileId, projectId, filename, file, metadata, linkedFilesSubfolderOverride, httpContext, config, lexBoxDb, newFilesAllowed: false);
93+
var result = await HandleFileUpload(fileId, projectId, filename, file, metadata, linkedFilesSubfolderOverride, httpContext, config, lexBoxDb, mediaFileService, newFilesAllowed: false);
9694
return result;
9795
}
9896

@@ -106,23 +104,26 @@ public static async Task<Results<Ok<PostFileResult>, Created<PostFileResult>, No
106104
[FromForm] string? linkedFilesSubfolderOverride,
107105
HttpContext httpContext,
108106
IOptions<FwHeadlessConfig> config,
107+
MediaFileService mediaFileService,
109108
LexBoxDbContext lexBoxDb)
110109
{
111-
var result = await HandleFileUpload(fileId, projectId, filename, file, metadata, linkedFilesSubfolderOverride, httpContext, config, lexBoxDb, newFilesAllowed: true);
110+
var result = await HandleFileUpload(fileId, projectId, filename, file, metadata, linkedFilesSubfolderOverride, httpContext, config, lexBoxDb, mediaFileService, newFilesAllowed: true);
112111
return result;
113112
}
114113

115-
public static async Task<Results<Ok<PostFileResult>, Created<PostFileResult>, NotFound, BadRequest<FileUploadErrorMessage>, ProblemHttpResult>> HandleFileUpload(
116-
Guid? fileId,
117-
Guid projectId,
118-
string? filename,
119-
IFormFile file,
120-
FileMetadata? metadata,
121-
string? linkedFilesSubfolderOverride,
122-
HttpContext httpContext,
123-
IOptions<FwHeadlessConfig> config,
124-
LexBoxDbContext lexBoxDb,
125-
bool newFilesAllowed)
114+
public static async
115+
Task<Results<Ok<PostFileResult>, Created<PostFileResult>, NotFound, BadRequest<FileUploadErrorMessage>,
116+
ProblemHttpResult>> HandleFileUpload(Guid? fileId,
117+
Guid projectId,
118+
string? filename,
119+
IFormFile file,
120+
FileMetadata? metadata,
121+
string? linkedFilesSubfolderOverride,
122+
HttpContext httpContext,
123+
IOptions<FwHeadlessConfig> config,
124+
LexBoxDbContext lexBoxDb,
125+
MediaFileService mediaFileService,
126+
bool newFilesAllowed)
126127
{
127128
if (CheckUploadSize(file, httpContext, config) is {} result) return result;
128129
MediaFile? mediaFile;
@@ -138,7 +139,7 @@ public static async Task<Results<Ok<PostFileResult>, Created<PostFileResult>, No
138139
linkedFilesSubfolderOverride,
139140
newFilesAllowed,
140141
httpContext,
141-
config);
142+
mediaFileService);
142143
}
143144
catch (NotFoundException) { return TypedResults.NotFound(); }
144145
catch (UploadedFilesCannotBeMovedToNewProjects) { return TypedResults.BadRequest(FileUploadErrorMessage.UploadedFilesCannotBeMovedToNewProjects); }
@@ -178,54 +179,6 @@ public static async Task<Results<Ok<PostFileResult>, Created<PostFileResult>, No
178179
return null;
179180
}
180181

181-
private static async Task<long> WriteFileToDisk(string filePath, Stream contents)
182-
{
183-
if (contents is null) return 0;
184-
long startPosition = 0;
185-
try
186-
{
187-
startPosition = contents.Position;
188-
}
189-
catch { }
190-
// First write to temp file, then move file into place, overwriting existing file
191-
// That way files will be replaced atomically, and a failure halfway through the process won't result in the existing file being lost
192-
string tempFile = "";
193-
try
194-
{
195-
var dirName = Path.GetDirectoryName(filePath);
196-
if (dirName is not null) Directory.CreateDirectory(dirName);
197-
tempFile = Path.Join(dirName, Path.GetRandomFileName());
198-
await using (var writeStream = File.Open(tempFile, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite))
199-
{
200-
await contents.CopyToAsync(writeStream);
201-
}
202-
File.Move(tempFile, filePath, overwrite: true);
203-
}
204-
finally
205-
{
206-
// If anything fails, delete temp file
207-
if (!string.IsNullOrEmpty(tempFile) && File.Exists(tempFile)) SafeDelete(tempFile);
208-
}
209-
long endPosition = 0;
210-
try
211-
{
212-
endPosition = contents.Position;
213-
}
214-
catch { }
215-
var calcLength = endPosition - startPosition;
216-
if (calcLength == 0)
217-
{
218-
// Either the stream was empty, or its Position attribute wasn't reliable, so we need
219-
// to look at the file we just wrote to determine the size
220-
var fileInfo = new FileInfo(filePath);
221-
return fileInfo.Length;
222-
}
223-
else
224-
{
225-
return calcLength;
226-
}
227-
}
228-
229182
private static async Task AddEntityTagMetadata(MediaFile mediaFile, string filePath)
230183
{
231184
mediaFile.InitializeMetadataIfNeeded(filePath);
@@ -238,7 +191,8 @@ private static async Task<bool> AddEntityTagMetadataIfNotPresent(MediaFile media
238191
{
239192
if (mediaFile.Metadata?.Sha256Hash is null)
240193
{
241-
await AddEntityTagMetadata(mediaFile, filePath);
194+
mediaFile.InitializeMetadataIfNeeded(filePath);
195+
mediaFile.Metadata.Sha256Hash = await MediaFileService.Sha256OfFile(filePath);
242196
return true;
243197
}
244198
return false;
@@ -284,13 +238,7 @@ private static async Task<FileMetadata> InitMetadata(FileMetadata? metadata, IFo
284238
return metadata;
285239
}
286240

287-
private class NotFoundException : Exception;
288-
private class UploadedFilesCannotBeMovedToNewProjects : Exception;
289-
private class UploadedFilesCannotBeMovedToDifferentLinkedFilesSubfolders : Exception;
290-
private class ProjectFolderNotFoundInFwHeadless : Exception;
291-
private class FileTooLarge : Exception;
292-
private static async Task<(MediaFile, bool newFile)> CreateOrUpdateMediaFile(
293-
LexBoxDbContext lexBoxDb,
241+
private static async Task<(MediaFile, bool newFile)> CreateOrUpdateMediaFile(LexBoxDbContext lexBoxDb,
294242
Guid? fileId,
295243
Guid projectId,
296244
string? filename,
@@ -299,7 +247,7 @@ private class FileTooLarge : Exception;
299247
string? subfolderOverride,
300248
bool newFilesAllowed,
301249
HttpContext httpContext,
302-
IOptions<FwHeadlessConfig> config)
250+
MediaFileService mediaFileService)
303251
{
304252
if (fileId is null || fileId.Value == default)
305253
{
@@ -310,7 +258,7 @@ private class FileTooLarge : Exception;
310258
if (mediaFile is null && !newFilesAllowed)
311259
{
312260
// PUT requests must modify an existing file and return 404 if it doesn't exist
313-
throw new NotFoundException();
261+
throw NotFoundException.ForType<MediaFile>();
314262
}
315263

316264
// If no filename specified in form, get it from uploaded file
@@ -343,40 +291,17 @@ private class FileTooLarge : Exception;
343291
}
344292

345293
var project = await lexBoxDb.Projects.FindAsync(projectId);
346-
if (project is null) throw new NotFoundException();
347-
var projectFolder = config.Value.GetFwDataProject(project.Code, projectId).ProjectFolder;
348-
await WriteFileAndUpdateMediaFileMetadata(lexBoxDb, mediaFile, projectFolder, file, config.Value.MaxUploadFileSizeBytes);
294+
if (project is null) throw NotFoundException.ForType<Project>();
295+
await SaveMediaFile(mediaFileService, mediaFile, file);
349296
return (mediaFile, newFile);
350297
}
351298

352-
private static async Task WriteFileAndUpdateMediaFileMetadata(LexBoxDbContext lexBoxDb, MediaFile mediaFile, string projectFolder, IFormFile file, long maxUploadSize)
299+
private static async Task SaveMediaFile(MediaFileService mediaFileService,
300+
MediaFile mediaFile,
301+
IFormFile file)
353302
{
354-
if (!Directory.Exists(projectFolder))
355-
{
356-
throw new ProjectFolderNotFoundInFwHeadless();
357-
}
358-
359-
var filePath = Path.Join(projectFolder, mediaFile.Filename);
360-
if (mediaFile.Metadata is not null) mediaFile.Metadata.SizeInBytes = (int)file.Length;
361-
long writtenLength = 0;
362-
await using (var readStream = file.OpenReadStream())
363-
{
364-
writtenLength = await WriteFileToDisk(filePath, readStream);
365-
}
366-
if (writtenLength > maxUploadSize)
367-
{
368-
SafeDeleteMediaFile(mediaFile, projectFolder, lexBoxDb);
369-
await lexBoxDb.SaveChangesAsync();
370-
throw new FileTooLarge();
371-
}
372-
if (writtenLength != file.Length)
373-
{
374-
// TODO: Log warning about mismatched length?
375-
if (mediaFile.Metadata is not null) mediaFile.Metadata.SizeInBytes = (int)writtenLength;
376-
}
377-
await AddEntityTagMetadata(mediaFile, filePath);
378-
mediaFile.UpdateUpdatedDate();
379-
await lexBoxDb.SaveChangesAsync();
303+
await using var readStream = file.OpenReadStream();
304+
await mediaFileService.SaveMediaFile(mediaFile, readStream);
380305
}
381306

382307
private static string? GuessSubfolderFromMimeType(string? mimeType)
@@ -389,27 +314,4 @@ private static async Task WriteFileAndUpdateMediaFileMetadata(LexBoxDbContext le
389314
if (mimeType == "application/mp4") return "AudioVisual"; // Some apps don't want to commit to audio/ or video/, but we don't care which it is
390315
return null;
391316
}
392-
393-
private static void SafeDelete(string filePath)
394-
{
395-
// Delete file at path, ignoring all errors such as "file not found"
396-
try { File.Delete(filePath); }
397-
catch { }
398-
}
399-
400-
private static void SafeDeleteDirectory(string dirPath, bool recursive = false)
401-
{
402-
// Delete file at path, ignoring all errors such as "directory not empty"
403-
try { Directory.Delete(dirPath, recursive); }
404-
catch { }
405-
}
406-
407-
private static void SafeDeleteMediaFile(MediaFile mediaFile, string projectFolder, LexBoxDbContext lexBoxDb)
408-
{
409-
var filePath = Path.Join(projectFolder, mediaFile.Filename);
410-
SafeDelete(filePath);
411-
var dirPath = Path.Join(projectFolder, mediaFile.Id.ToString());
412-
SafeDeleteDirectory(dirPath); // Will not delete dir if not empty, but that's OK
413-
lexBoxDb.Files.Remove(mediaFile);
414-
}
415317
}

backend/FwHeadless/FwHeadlessConfig.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ namespace FwHeadless;
99
public class FwHeadlessConfig
1010
{
1111
[Required, Url, RegularExpression(@"^.+/$", ErrorMessage = "Must end with '/'")]
12-
public required string LexboxUrl { get; init; }
12+
public required string LexboxUrl { get; set; }
1313
public string HgWebUrl => $"{LexboxUrl}hg/";
1414
[Required]
15-
public required string LexboxUsername { get; init; }
15+
public required string LexboxUsername { get; set; }
1616
[Required]
17-
public required string LexboxPassword { get; init; }
17+
public required string LexboxPassword { get; set; }
1818
[Required]
19-
public required string ProjectStorageRoot { get; init; }
19+
public required string ProjectStorageRoot { get; set; }
2020
[Required]
21-
public required string MediaFileAuthority { get; init; }
21+
public required string MediaFileAuthority { get; set; }
2222
public int MaxUploadFileSizeKb { get; init; } = 10240;
2323
public long MaxUploadFileSizeBytes => MaxUploadFileSizeKb * 1024;
2424
public string FdoDataModelVersion { get; init; } = "7000072";

backend/FwHeadless/FwHeadlessKernel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace FwHeadless;
1313
public static class FwHeadlessKernel
1414
{
1515
public const string LexboxHttpClientName = "LexboxHttpClient";
16-
public static void AddFwHeadless(this IServiceCollection services)
16+
public static IServiceCollection AddFwHeadless(this IServiceCollection services)
1717
{
1818
services
1919
.AddLogging(builder => builder.AddConsole().AddDebug().AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning));
@@ -46,5 +46,6 @@ public static void AddFwHeadless(this IServiceCollection services)
4646
{
4747
client.BaseAddress = new Uri(provider.GetRequiredService<IOptions<FwHeadlessConfig>>().Value.LexboxUrl);
4848
}).AddHttpMessageHandler<HttpClientAuthHandler>();
49+
return services;
4950
}
5051
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace FwHeadless.Media;
2+
3+
public class UploadedFilesCannotBeMovedToNewProjects : Exception;
4+
5+
public class UploadedFilesCannotBeMovedToDifferentLinkedFilesSubfolders : Exception;
6+
7+
public class ProjectFolderNotFoundInFwHeadless : Exception;
8+
9+
public class FileTooLarge : Exception;

0 commit comments

Comments
 (0)