A file lives exactly as long as something holds it
Deleting a post, editing media out, replacing an avatar or a header, and removing a whole root deleted no file: every one stayed on disk and publicly served from /media/files with a year-long immutable cache, its row orphaned. Now every upload is a row, profile pictures too (Kind avatar or header, ProfileOfAvatarId), and each of those acts trashes what it held, as does an upload never posted for a day and a dropped scheduled post. A trashed row is marked in one conditional update (an upload attached meanwhile is left alone), its files move into media-trash, beside the media root and outside what /media/files serves, and the janitor deletes them a day later. Nothing is deleted for looking unused. Along the way: a profile picture that isn't an image, or can't be read, answers 422 instead of being silently ignored with a 200; a removed root's scheduled posts are dropped, so nothing of it publishes later; media rows get indexes (they had none), and the janitor's first pass comes five minutes after boot instead of an hour. `PrivaPub admin media audit [--fix]` compares the disk with the database. With --fix (as www-data) it gives the pictures personas show today a row, and trashes media of deleted posts or personas, rows whose files are missing, and files nothing holds: the leftovers of every deletion until now. MediaLifecycleTests covers each act, that the trash is never served, and the audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
52d201eee9
commit
bb680e8cb8
15 files changed
+462
-36
No files matched your search
@@ -27,9 +27,20 @@ namespace PrivaPub.Domain.Media
|
||||
string Root { get; }
|
||||
string ProxyRoot { get; }
|
||||
string Url(string relativePath);
|
||||
/// <summary>Where trashed files wait for the janitor: the sibling of Root, on the same disk (a move is a rename) and
|
||||
/// outside what /media/files serves. (Not a dot-prefixed folder inside Root: the file provider only hides a file whose
|
||||
/// own name starts with a dot.)</summary>
|
||||
string TrashRoot { get; }
|
||||
Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token);
|
||||
Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token);
|
||||
Task Delete(MediaAttachment attachment);
|
||||
/// <summary>A persona's new avatar or header ("avatar" or "header"), cropped to its size, as a row of its own.</summary>
|
||||
Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token);
|
||||
/// <summary>Trashes the media <paramref name="which"/> matches (and that isn't trashed yet): each row is marked in one
|
||||
/// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once.</summary>
|
||||
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token);
|
||||
/// <summary>Moves a trashed row's files out of what is served, if they are still there (a crash between the mark and the move).</summary>
|
||||
void Hide(MediaAttachment trashed);
|
||||
/// <summary>Deletes a trashed row's files and the row.</summary>
|
||||
Task Purge(MediaAttachment trashed, CancellationToken token);
|
||||
}
|
||||
|
||||
public class MediaService : IMediaService
|
||||
@@ -62,6 +73,8 @@ namespace PrivaPub.Domain.Media
|
||||
|
||||
public string ProxyRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-proxy";
|
||||
|
||||
public string TrashRoot => Root.TrimEnd(Path.DirectorySeparatorChar) + "-trash";
|
||||
|
||||
public string Url(string relativePath) => relativePath == default ? default : $"{_localActors.BaseAddress}/media/files/{relativePath.Replace('\\', '/')}";
|
||||
|
||||
public async Task<MediaOutcome> Upload(LocalActor owner, IFormFile file, string description, string focus, CancellationToken token)
|
||||
@@ -118,34 +131,86 @@ namespace PrivaPub.Domain.Media
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
public async Task<string> ProfileImage(IFormFile file, int width, int height, CancellationToken token)
|
||||
public async Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token)
|
||||
{
|
||||
if (file == default || file.Length == 0 || file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return default;
|
||||
var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
if (!ImageTypes.Contains(contentType))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
|
||||
if (file.Length > _options.CurrentValue.MaxImageBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var buffer = new MemoryStream();
|
||||
await stream.CopyToAsync(buffer, token);
|
||||
byte[] bytes;
|
||||
int outWidth, outHeight;
|
||||
try
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
|
||||
using var flat = Flatten(image);
|
||||
return Url(await Save(flat.WriteToBuffer(".jpg[Q=85,keep=none]"), "jpg", token));
|
||||
bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]");
|
||||
(outWidth, outHeight) = (flat.Width, flat.Height);
|
||||
}
|
||||
catch (VipsException)
|
||||
catch (VipsException ex)
|
||||
{
|
||||
return default;
|
||||
_logger.LogInformation("Refused a profile picture: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
OwnerAvatarId = avatarId,
|
||||
ProfileOfAvatarId = avatarId,
|
||||
Kind = kind,
|
||||
ContentType = "image/jpeg",
|
||||
FilePath = await Save(bytes, "jpg", token),
|
||||
Size = bytes.Length,
|
||||
Width = outWidth,
|
||||
Height = outHeight,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
public async Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token)
|
||||
{
|
||||
var trashed = 0L;
|
||||
foreach (var candidate in await DB.Default.Find<MediaAttachment>().Match(which).Match(m => m.TrashedAt == null).ExecuteAsync(token))
|
||||
{
|
||||
var marked = await DB.Default.Update<MediaAttachment>()
|
||||
.Match(which).Match(m => m.ID == candidate.ID && m.TrashedAt == null)
|
||||
.Modify(m => m.TrashedAt, DateTime.UtcNow)
|
||||
.Modify(m => m.TrashReason, reason)
|
||||
.ExecuteAsync(token);
|
||||
if (marked.ModifiedCount == 0)
|
||||
continue;
|
||||
Hide(candidate);
|
||||
trashed++;
|
||||
}
|
||||
return trashed;
|
||||
}
|
||||
|
||||
public void Hide(MediaAttachment trashed)
|
||||
{
|
||||
foreach (var relative in new[] { trashed.FilePath, trashed.PreviewPath }.Where(p => !string.IsNullOrEmpty(p)))
|
||||
{
|
||||
var served = Path.Combine(Root, relative);
|
||||
if (!File.Exists(served))
|
||||
continue;
|
||||
var hidden = Path.Combine(TrashRoot, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(hidden)!);
|
||||
File.Move(served, hidden, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task Delete(MediaAttachment attachment)
|
||||
public async Task Purge(MediaAttachment trashed, CancellationToken token)
|
||||
{
|
||||
foreach (var path in new[] { attachment.FilePath, attachment.PreviewPath }.Where(p => p != default))
|
||||
{
|
||||
var full = Path.Combine(Root, path);
|
||||
if (File.Exists(full))
|
||||
File.Delete(full);
|
||||
}
|
||||
return DB.Default.DeleteAsync<MediaAttachment>(attachment.ID);
|
||||
foreach (var relative in new[] { trashed.FilePath, trashed.PreviewPath }.Where(p => !string.IsNullOrEmpty(p)))
|
||||
foreach (var full in new[] { Path.Combine(TrashRoot, relative), Path.Combine(Root, relative) })
|
||||
if (File.Exists(full))
|
||||
File.Delete(full);
|
||||
await DB.Default.DeleteAsync<MediaAttachment>(m => m.ID == trashed.ID && m.TrashedAt != null);
|
||||
}
|
||||
|
||||
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated)
|
||||
|
||||
Reference in new issue
Block a user