Media are reserved for one post, atomically
An upload's owner checked that it was free, then attached it in a second step, so two posts asking for the same upload at once could both get it; a post now, or an edit, could take media a scheduled post held, which then failed when its time came (only logged) and was deleted; and a persona deleted or banned after scheduling a post still published it. Now a post claims its media in one conditional update before anything is written (its id is minted first), and a post that loses the race is refused with 422, the media it took put back. Scheduling reserves media the same way. Media held by a scheduled post are that post's alone, and its job publishes only for a persona still there whose root is neither deleted nor banned; otherwise what it held is trashed. MastodonScheduledStatusesTests: a post now and an edit can't take scheduled media, two racing posts never both get an upload, a gone persona's scheduled post never publishes. 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
bb680e8cb8
commit
65938bb2a0
4 files changed
+117
-14
No files matched your search
@@ -99,5 +99,60 @@ namespace PrivaPub.Tests.Http
|
|||||||
Assert.Equal(published.Text("id"), media.PostId);
|
Assert.Equal(published.Text("id"), media.PostId);
|
||||||
Assert.Null(media.ScheduledStatusId);
|
Assert.Null(media.ScheduledStatusId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<string> 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<MediaAttachment>().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<Models.User.Avatar>().MatchID(gone.Persona.Id).Modify(a => a.DeletionAt, DateTime.UtcNow).ExecuteAsync(Token);
|
||||||
|
|
||||||
|
await DB.Default.Update<ScheduledStatus>().MatchID(id).Modify(s => s.ScheduledAt, DateTime.UtcNow.AddSeconds(-1)).ExecuteAsync(Token);
|
||||||
|
await DB.Default.Update<Job>().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<Models.Post.Post>().Match(p => p.GroupUserId == gone.Persona.Id && p.Text == "from beyond").ExecuteAnyAsync(Token));
|
||||||
|
Assert.False(await DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id).ExecuteAnyAsync(Token));
|
||||||
|
Assert.NotNull((await DB.Default.Find<MediaAttachment>().OneAsync(mediaId, Token)).TrashedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
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 };
|
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)
|
if (mediaIds.Count > 0)
|
||||||
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => mediaIds.Contains(m.ID)).Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
|
{
|
||||||
|
var reserved = await DB.Default.Update<Models.Media.MediaAttachment>()
|
||||||
|
.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<Models.Media.MediaAttachment>().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);
|
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
||||||
if (cacheKey != default)
|
if (cacheKey != default)
|
||||||
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));
|
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ using PrivaPub.Models.Jobs;
|
|||||||
using PrivaPub.Models.Media;
|
using PrivaPub.Models.Media;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.Social;
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
|
||||||
namespace PrivaPub.Domain.Statuses
|
namespace PrivaPub.Domain.Statuses
|
||||||
{
|
{
|
||||||
@@ -19,8 +21,9 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
public const int MaxWaiting = 300;
|
public const int MaxWaiting = 300;
|
||||||
public const int MaxPerDay = 25;
|
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,
|
Text = p.Text,
|
||||||
PlainText = true,
|
PlainText = true,
|
||||||
SpoilerText = p.SpoilerText,
|
SpoilerText = p.SpoilerText,
|
||||||
@@ -66,6 +69,15 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
RunAt = scheduled.ScheduledAt
|
RunAt = scheduled.ScheduledAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// whether a persona may still publish: neither deleted nor its root deleted or banned
|
||||||
|
public static async Task<bool> MayPublish(string avatarId, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (!await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token))
|
||||||
|
return false;
|
||||||
|
var rootIds = (await DB.Default.Find<RootToAvatar>().Match(r => r.AvatarId == avatarId).ExecuteAsync(token)).Select(r => r.RootId).ToList();
|
||||||
|
return await DB.Default.Find<RootUser>().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
|
// 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) =>
|
public static Task Release(string scheduledId, CancellationToken token) =>
|
||||||
DB.Default.Update<MediaAttachment>().Match(m => m.ScheduledStatusId == scheduledId).Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
|
DB.Default.Update<MediaAttachment>().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))
|
if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30))
|
||||||
return JobOutcome.Done;//dropped, or moved to a later time that has its own job
|
return JobOutcome.Done;//dropped, or moved to a later time that has its own job
|
||||||
using var scope = _scopes.CreateScope();
|
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<IMediaService>().Trash(m => m.ScheduledStatusId == scheduled.ID, "its author is gone", token);
|
||||||
|
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
||||||
|
return JobOutcome.Done;
|
||||||
|
}
|
||||||
var author = await scope.ServiceProvider.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, scheduled.AvatarId, token);
|
var author = await scope.ServiceProvider.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, scheduled.AvatarId, token);
|
||||||
if (author != default)
|
if (author != default)
|
||||||
{
|
{
|
||||||
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>().Publish(author, ScheduledStatuses.Draft(scheduled.Params), token);
|
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>().Publish(author, ScheduledStatuses.Draft(scheduled.Params, scheduled.ID), token);
|
||||||
if (!outcome.Ok)
|
if (!outcome.Ok)
|
||||||
_logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error);
|
_logger.LogWarning("Scheduled status {Id} of {Avatar} could not be published: {Error}", scheduled.ID, scheduled.AvatarId, outcome.Error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
public string ConversationId { get; init; }
|
public string ConversationId { get; init; }
|
||||||
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
public IReadOnlyList<string> Recipients { get; init; } = Array.Empty<string>();
|
||||||
public IReadOnlyList<string> MediaIds { get; init; }
|
public IReadOnlyList<string> 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? Latitude { get; init; }
|
||||||
public double? Longitude { get; init; }
|
public double? Longitude { get; init; }
|
||||||
public double? RangeKm { get; init; }
|
public double? RangeKm { get; init; }
|
||||||
@@ -109,7 +111,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
{
|
{
|
||||||
if (TooLong(draft) is { } tooLong)
|
if (TooLong(draft) is { } tooLong)
|
||||||
return 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)
|
if (media == default)
|
||||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||||
if (string.IsNullOrWhiteSpace(draft.Text) && media.Count == 0 && draft.Poll == default)
|
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.ID = (string)post.GenerateNewID();
|
||||||
post.ObjectURI = author.PostUri(post.ID);
|
post.ObjectURI = author.PostUri(post.ID);
|
||||||
post.Url = author.PostHtmlUrl(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;
|
post.LocalQuotePolicy = QuotePolicies.IsKnown(draft.QuotePolicy) ? draft.QuotePolicy : author.Settings.QuotePolicy ?? QuotePolicies.Public;
|
||||||
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
if (quoted is { IsFederatedCopy: false } && post.QuoteState == QuoteState.Accepted
|
||||||
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
&& await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token) is { } quotedAuthor)
|
||||||
@@ -270,7 +275,6 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
post.ActivityURI = create?["id"]?.GetValue<string>();
|
post.ActivityURI = create?["id"]?.GetValue<string>();
|
||||||
|
|
||||||
await DB.Default.SaveAsync(post, token);
|
await DB.Default.SaveAsync(post, token);
|
||||||
await Attach(media, post.ID, token);
|
|
||||||
if (parent != default && Counted.Reply(post))
|
if (parent != default && Counted.Reply(post))
|
||||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||||
await _fanout.Distribute(post, token);
|
await _fanout.Distribute(post, token);
|
||||||
@@ -297,7 +301,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||||
if (TooLong(draft) is { } tooLong)
|
if (TooLong(draft) is { } tooLong)
|
||||||
return 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)
|
if (draft.MediaIds != default && media == default)
|
||||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||||
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).Count == 0 && post.Media.Count == 0)
|
if (string.IsNullOrWhiteSpace(draft.Text) && (media ?? new List<MediaAttachment>()).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);
|
var rendered = plain ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token);
|
||||||
if (media != default)
|
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();
|
post.Media = media.Select(ToPostMedia).ToList();
|
||||||
await Attach(media, post.ID, token);
|
|
||||||
// what the edit left out is no longer held by anything
|
// what the edit left out is no longer held by anything
|
||||||
var kept = media.Select(m => m.ID).ToList();
|
var kept = media.Select(m => m.ID).ToList();
|
||||||
await _media.Trash(m => m.PostId == post.ID && !kept.Contains(m.ID), "edited out", token);
|
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<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> 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<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, string scheduledId, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (ids == default || ids.Count == 0)
|
if (ids == default || ids.Count == 0)
|
||||||
return new List<MediaAttachment>();
|
return new List<MediaAttachment>();
|
||||||
@@ -697,17 +703,28 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
var wanted = ids.Distinct().ToList();
|
var wanted = ids.Distinct().ToList();
|
||||||
var found = await DB.Default.Find<MediaAttachment>()
|
var found = await DB.Default.Find<MediaAttachment>()
|
||||||
.Match(m => wanted.Contains(m.ID) && m.OwnerAvatarId == author.Id && (m.PostId == null || m.PostId == postId)
|
.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);
|
.ExecuteAsync(token);
|
||||||
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
return found.Count == wanted.Count ? wanted.Select(id => found.First(m => m.ID == id)).ToList() : default;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task Attach(IEnumerable<MediaAttachment> 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<bool> Claim(IReadOnlyList<MediaAttachment> media, string postId, string scheduledId, CancellationToken token)
|
||||||
{
|
{
|
||||||
var ids = media.Select(m => m.ID).ToList();
|
var ids = media.Select(m => m.ID).ToList();
|
||||||
if (ids.Count > 0)
|
if (ids.Count == 0)
|
||||||
await DB.Default.Update<MediaAttachment>().Match(m => ids.Contains(m.ID))
|
return true;
|
||||||
.Modify(m => m.PostId, postId).Modify(m => m.AttachedAt, DateTime.UtcNow).ExecuteAsync(token);
|
var claimed = await DB.Default.Update<MediaAttachment>()
|
||||||
|
.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<MediaAttachment>().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()
|
PostMedia ToPostMedia(MediaAttachment attachment) => new()
|
||||||
|
|||||||
Reference in new issue
Block a user