diff --git a/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs b/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs index 0a8fca0..13d890d 100644 --- a/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs +++ b/PrivaPub.Tests/Http/MastodonScheduledStatusesTests.cs @@ -99,5 +99,60 @@ namespace PrivaPub.Tests.Http Assert.Equal(published.Text("id"), media.PostId); Assert.Null(media.ScheduledStatusId); } + + async Task Picture(Mastodon account) + { + var upload = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(16, 16), "image/jpeg", "a.jpg")); + return (await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = upload })).Ok().Body.Text("id"); + } + + // a scheduled post's media are its alone: a post now, or an edit, can't take them + [Fact] + public async Task A_scheduled_posts_media_cannot_be_taken_by_a_post_now() + { + var alice = await _host.Mastodon("alice"); + var mediaId = await Picture(alice); + (await alice.Client.Post("/api/v1/statuses", ("status", "later"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok(); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "now"), ("media_ids[]", mediaId))).Status); + var plain = (await alice.Client.Post("/api/v1/statuses", ("status", "plain"))).Ok().Body.Text("id"); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put($"/api/v1/statuses/{plain}", ("status", "edited"), ("media_ids[]", mediaId))).Status); + var media = await DB.Default.Find().OneAsync(mediaId, Token); + Assert.Null(media.PostId); + Assert.NotNull(media.ScheduledStatusId); + } + + // two posts asking for the same upload at once: exactly one gets it + [Fact] + public async Task Two_posts_racing_for_one_upload_never_both_get_it() + { + var alice = await _host.Mastodon("alice"); + for (var round = 0; round < 5; round++) + { + var mediaId = await Picture(alice); + var answers = await Task.WhenAll(Enumerable.Range(0, 2).Select(i => + alice.Client.Post("/api/v1/statuses", ("status", $"race {round} {i}"), ("media_ids[]", mediaId)))); + Assert.Single(answers, a => a.Status == HttpStatusCode.OK); + Assert.Single(answers, a => a.Status == HttpStatusCode.UnprocessableEntity); + } + } + + // a persona deleted since it scheduled a post publishes nothing, and what the post held is trashed + [Fact] + public async Task A_gone_personas_scheduled_post_never_publishes() + { + var gone = await _host.Mastodon($"gone{Guid.NewGuid():N}"[..12]); + var mediaId = await Picture(gone); + var id = (await gone.Client.Post("/api/v1/statuses", ("status", "from beyond"), ("media_ids[]", mediaId), ("scheduled_at", In(TimeSpan.FromMinutes(10))))).Ok().Object.Text("id"); + await DB.Default.Update().MatchID(gone.Persona.Id).Modify(a => a.DeletionAt, DateTime.UtcNow).ExecuteAsync(Token); + + await DB.Default.Update().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token); + await DB.Default.Update().Match(j => j.Kind == JobKind.PublishScheduled && j.Payload == id).Modify(j => j.RunAt, DateTime.UtcNow).ExecuteAsync(Token); + await _host.Run(j => j.Kind == JobKind.PublishScheduled && j.Payload == id, Token); + + Assert.False(await DB.Default.Find().Match(p => p.GroupUserId == gone.Persona.Id && p.Text == "from beyond").ExecuteAnyAsync(Token)); + Assert.False(await DB.Default.Find().Match(s => s.ID == id).ExecuteAnyAsync(Token)); + Assert.NotNull((await DB.Default.Find().OneAsync(mediaId, Token)).TrashedAt); + } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 8611564..72f34b9 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -116,9 +116,21 @@ namespace PrivaPub.Api.Mastodon.Controllers return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many"); var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked }; - await DB.Default.SaveAsync(scheduled, token); + scheduled.ID = (string)scheduled.GenerateNewID(); + // reserved in one conditional update: a post or another scheduled post taking one meanwhile wins if (mediaIds.Count > 0) - await DB.Default.Update().Match(m => mediaIds.Contains(m.ID)).Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token); + { + var reserved = await DB.Default.Update() + .Match(m => mediaIds.Contains(m.ID) && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null) + .Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token); + if (reserved.MatchedCount != mediaIds.Count) + { + await DB.Default.Update().Match(m => m.ScheduledStatusId == scheduled.ID) + .Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token); + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many"); + } + } + await DB.Default.SaveAsync(scheduled, token); await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token); if (cacheKey != default) _cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1)); diff --git a/PrivaPub/Domain/Statuses/ScheduledStatuses.cs b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs index f73c8bb..b2f9cd6 100644 --- a/PrivaPub/Domain/Statuses/ScheduledStatuses.cs +++ b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs @@ -7,6 +7,8 @@ using PrivaPub.Models.Jobs; using PrivaPub.Models.Media; using PrivaPub.Models.Post; using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.Domain.Media; namespace PrivaPub.Domain.Statuses { @@ -19,8 +21,9 @@ namespace PrivaPub.Domain.Statuses public const int MaxWaiting = 300; public const int MaxPerDay = 25; - public static StatusDraft Draft(ScheduledParams p) => new() + public static StatusDraft Draft(ScheduledParams p, string scheduledId = default) => new() { + ScheduledStatusId = scheduledId, Text = p.Text, PlainText = true, SpoilerText = p.SpoilerText, @@ -66,6 +69,15 @@ namespace PrivaPub.Domain.Statuses RunAt = scheduled.ScheduledAt }; + // whether a persona may still publish: neither deleted nor its root deleted or banned + public static async Task MayPublish(string avatarId, CancellationToken token) + { + if (!await DB.Default.Find().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)) + return false; + var rootIds = (await DB.Default.Find().Match(r => r.AvatarId == avatarId).ExecuteAsync(token)).Select(r => r.RootId).ToList(); + return await DB.Default.Find().Match(u => rootIds.Contains(u.ID) && u.DeletedAt == null && !u.IsBanned).ExecuteAnyAsync(token); + } + // the post's media go back to being plain uploads (the janitor's after a day), or are attached to the post by now public static Task Release(string scheduledId, CancellationToken token) => DB.Default.Update().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token); @@ -93,10 +105,17 @@ namespace PrivaPub.Domain.Statuses if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30)) return JobOutcome.Done;//dropped, or moved to a later time that has its own job using var scope = _scopes.CreateScope(); + // a persona deleted or banned since it was scheduled publishes nothing, and what it held is trashed + if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token)) + { + await scope.ServiceProvider.GetRequiredService().Trash(m => m.ScheduledStatusId == scheduled.ID, "its author is gone", token); + await DB.Default.DeleteAsync(scheduled.ID); + return JobOutcome.Done; + } var author = await scope.ServiceProvider.GetRequiredService().FindById(LocalActorKind.Person, scheduled.AvatarId, token); if (author != default) { - var outcome = await scope.ServiceProvider.GetRequiredService().Publish(author, ScheduledStatuses.Draft(scheduled.Params), token); + var outcome = await scope.ServiceProvider.GetRequiredService().Publish(author, ScheduledStatuses.Draft(scheduled.Params, scheduled.ID), token); if (!outcome.Ok) _logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error); } diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index a58b434..1cf98d1 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -38,6 +38,8 @@ namespace PrivaPub.Domain.Statuses public string ConversationId { get; init; } public IReadOnlyList Recipients { get; init; } = Array.Empty(); public IReadOnlyList MediaIds { get; init; } + // the scheduled post being published, whose media are reserved for it alone + public string ScheduledStatusId { get; init; } public double? Latitude { get; init; } public double? Longitude { get; init; } public double? RangeKm { get; init; } @@ -109,7 +111,7 @@ namespace PrivaPub.Domain.Statuses { if (TooLong(draft) is { } tooLong) return tooLong; - var media = await Media(author, draft.MediaIds, default, token); + var media = await Media(author, draft.MediaIds, default, draft.ScheduledStatusId, token); if (media == default) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default) @@ -225,6 +227,9 @@ namespace PrivaPub.Domain.Statuses post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); post.Url = author.PostHtmlUrl(post.ID); + // the media are this post's before anything is written: another post claiming them meanwhile wins, and this one is refused + if (!await Claim(media, post.ID, draft.ScheduledStatusId, token)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public; if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted && await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor) @@ -270,7 +275,6 @@ namespace PrivaPub.Domain.Statuses post.ActivityURI = create?["id"]?.GetValue(); await DB.Default.SaveAsync(post, token); - await Attach(media, post.ID, token); if (parent != default && Counted.Reply(post)) await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await _fanout.Distribute(post, token); @@ -297,7 +301,7 @@ namespace PrivaPub.Domain.Statuses return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); if (TooLong(draft) is { } tooLong) return tooLong; - var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token); + var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, default, token); if (draft.MediaIds != default && media == default) return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List()).Count == 0 && post.Media.Count == 0) @@ -318,8 +322,9 @@ namespace PrivaPub.Domain.Statuses var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); if (media != default) { + if (!await Claim(media, post.ID, default, token)) + return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid"); post.Media = media.Select(ToPostMedia).ToList(); - await Attach(media, post.ID, token); // what the edit left out is no longer held by anything var kept = media.Select(m => m.ID).ToList(); await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token); @@ -688,7 +693,8 @@ namespace PrivaPub.Domain.Statuses }; } - async Task> Media(LocalActor author, IReadOnlyList ids, string postId, CancellationToken token) + // the author's own uploads, not yet another post's (nor held by another scheduled post), nor trashed, nor pictures + async Task> Media(LocalActor author, IReadOnlyList ids, string postId, string scheduledId, CancellationToken token) { if (ids == default || ids.Count == 0) return new List(); @@ -697,17 +703,28 @@ namespace PrivaPub.Domain.Statuses var wanted = ids.Distinct().ToList(); var found = await DB.Default.Find() .Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId) - && m.TrashedAt == null && m.ProfileOfAvatarId == null) + && (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId) && m.TrashedAt == null && m.ProfileOfAvatarId == null) .ExecuteAsync(token); return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default; } - static async Task Attach(IEnumerable media, string postId, CancellationToken token) + // attaches the media in one conditional update, true when all of them are the post's; when another post took one + // meanwhile, the ones this call took go back as they were + static async Task Claim(IReadOnlyList media, string postId, string scheduledId, CancellationToken token) { var ids = media.Select(m => m.ID).ToList(); - if (ids.Count > 0) - await DB.Default.Update().Match(m => ids.Contains(m.ID)) - .Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token); + if (ids.Count == 0) + return true; + var claimed = await DB.Default.Update() + .Match(m => ids.Contains(m.ID) && (m.PostId == null || m.PostId == postId) && m.TrashedAt == null + && (m.ScheduledStatusId == null || m.ScheduledStatusId == scheduledId)) + .Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token); + if (claimed.MatchedCount == ids.Count) + return true; + var taken = media.Where(m => m.PostId == null).Select(m => m.ID).ToList(); + await DB.Default.Update().Match(m => taken.Contains(m.ID) && m.PostId == postId) + .Modify(m => m.PostId, null).Modify(m => m.AttachedAt, null).ExecuteAsync(token); + return false; } PostMedia ToPostMedia(MediaAttachment attachment) => new()