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
105 lines
4.4 KiB
C#
105 lines
4.4 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Media;
|
|
using PrivaPub.Models.Social;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
// Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for
|
|
// their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them.
|
|
public class ScheduledStatusesController : MastodonController
|
|
{
|
|
readonly IMediaService _media;
|
|
readonly IJobQueue _jobs;
|
|
|
|
public ScheduledStatusesController(IMediaService media, IJobQueue jobs)
|
|
{
|
|
_media = media;
|
|
_jobs = jobs;
|
|
}
|
|
|
|
public static async Task<object> View(ScheduledStatus scheduled, IMediaService media, CancellationToken token)
|
|
{
|
|
var p = scheduled.Params;
|
|
var ids = p.MediaIds;
|
|
var attachments = ids.Count == 0
|
|
? new List<MediaAttachment>()
|
|
: await DB.Default.Find<MediaAttachment>().Match(m => ids.Contains(m.ID)).ExecuteAsync(token);
|
|
return new
|
|
{
|
|
id = scheduled.ID,
|
|
scheduled_at = MastodonJson.Time(scheduled.ScheduledAt),
|
|
@params = new
|
|
{
|
|
text = p.Text,
|
|
poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals },
|
|
media_ids = ids.Count == 0 ? default : ids,
|
|
sensitive = p.Sensitive,
|
|
spoiler_text = p.SpoilerText,
|
|
visibility = p.Visibility,
|
|
in_reply_to_id = p.InReplyToId,
|
|
language = p.Language,
|
|
application_id = default(string),
|
|
scheduled_at = default(string),
|
|
idempotency = p.Idempotency,
|
|
with_rate_limit = false,
|
|
quoted_status_id = p.QuotedStatusId
|
|
},
|
|
media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default)
|
|
.Select(m => MediaController.View(media, m)).ToList()
|
|
};
|
|
}
|
|
|
|
Task<ScheduledStatus> Mine(string id, CancellationToken token) =>
|
|
DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token);
|
|
|
|
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
|
|
public async Task<IActionResult> All(CancellationToken token)
|
|
{
|
|
var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == MyId), s => s.ID, token);
|
|
Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
|
var views = new List<object>();
|
|
foreach (var scheduled in page)
|
|
views.Add(await View(scheduled, _media, token));
|
|
return Json(views);
|
|
}
|
|
|
|
[HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")]
|
|
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
|
await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError();
|
|
|
|
[HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Move(string id, CancellationToken token)
|
|
{
|
|
if (await Mine(id, token) is not { } scheduled)
|
|
return NotFoundError();
|
|
if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
|
|
if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
|
scheduled.ScheduledAt = at;
|
|
await DB.Default.Update<ScheduledStatus>().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token);
|
|
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
|
return Json(await View(scheduled, _media, token));
|
|
}
|
|
|
|
[HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Drop(string id, CancellationToken token)
|
|
{
|
|
if (await Mine(id, token) is not { } scheduled)
|
|
return NotFoundError();
|
|
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
|
// its media were the scheduled post's alone, never posted
|
|
await _media.Trash(m => m.ScheduledStatusId == scheduled.ID, "scheduled post dropped", token);
|
|
return Json(new { });
|
|
}
|
|
}
|
|
}
|