The context of a remote post showed only what PrivaPub happened to hold: replies from servers nobody here follows were never seen, and only the ancestors were ever fetched. Now a persona opening a public remote thread queues FetchReplies for the post and its root, at most once an hour each. The job reads the thread's own collection first (FEP-7888 `context`, which Mastodon 4.5+ serves with every reply at any depth; posts or, as FEP-f228 allows, the activities that made them), and otherwise the post's `replies` (PeerTube's `comments`) and the replies' own, two levels down. At most 5 pages and 100 posts a job, signed by the instance actor, never a persona; each post is fetched from its own origin and stored through StoreContext, so only public and unlisted ones are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
428 lines
21 KiB
C#
428 lines
21 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class StatusesController : MastodonController
|
|
{
|
|
const int MaxContext = 200;
|
|
public const int MaxPins = 5;
|
|
|
|
readonly IStatusService _statuses;
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IMemoryCache _cache;
|
|
readonly IQuoteService _quotes;
|
|
readonly IOutboxPublisher _outbox;
|
|
readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs;
|
|
readonly Domain.Media.IMediaService _media;
|
|
|
|
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
|
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media)
|
|
{
|
|
_jobs = jobs;
|
|
_media = media;
|
|
_quotes = quotes;
|
|
_outbox = outbox;
|
|
_statuses = statuses;
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
_cache = cache;
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Create(CancellationToken token)
|
|
{
|
|
var idempotency = Request.Headers["Idempotency-Key"].ToString();
|
|
var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}";
|
|
if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier))
|
|
{
|
|
var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token);
|
|
if (existing != default)
|
|
return Json(await _mapper.Status(existing, MyId, token));
|
|
}
|
|
var asked = new ScheduledParams
|
|
{
|
|
Text = Params.Get("status"),
|
|
SpoilerText = Params.Get("spoiler_text"),
|
|
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
|
|
Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility,
|
|
InReplyToId = Params.Get("in_reply_to_id"),
|
|
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
|
MediaIds = Params.List("media_ids").ToList(),
|
|
QuotedStatusId = Params.Get("quoted_status_id"),
|
|
QuotePolicy = Params.Get("quote_approval_policy"),
|
|
Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency,
|
|
Poll = Params.Has("poll[options]")
|
|
? new ScheduledPoll
|
|
{
|
|
Options = Params.List("poll[options]").ToList(),
|
|
ExpiresIn = Params.Int("poll[expires_in]") ?? 0,
|
|
Multiple = Params.Bool("poll[multiple]") == true,
|
|
HideTotals = Params.Bool("poll[hide_totals]") == true
|
|
}
|
|
: default
|
|
};
|
|
if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt)
|
|
return await Schedule(asked, scheduledAt, cacheKey, token);
|
|
var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token);
|
|
if (!outcome.Ok)
|
|
return Error(outcome.Status, outcome.Error);
|
|
if (cacheKey != default)
|
|
_cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1));
|
|
return Json(await _mapper.Status(outcome.Post, MyId, token));
|
|
}
|
|
|
|
// scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words,
|
|
// media of the persona's own, not yet posted) is checked now
|
|
async Task<IActionResult> Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token)
|
|
{
|
|
if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier)
|
|
&& await DB.Default.Find<ScheduledStatus>().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting)
|
|
return Json(await ScheduledStatusesController.View(waiting, _media, token));
|
|
if (!DateTime.TryParse(scheduledAt, 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, default, token) is { } refusal)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
|
if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
|
var mediaIds = asked.MediaIds.Distinct().ToList();
|
|
if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync<Models.Media.MediaAttachment>(
|
|
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null, token) != mediaIds.Count)
|
|
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);
|
|
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);
|
|
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));
|
|
return Json(await ScheduledStatusesController.View(scheduled, _media, token));
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Many(CancellationToken token)
|
|
{
|
|
var ids = Params.List("id").Take(20).ToList();
|
|
var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
|
|
var visible = new List<PostEntity>();
|
|
foreach (var post in posts)
|
|
if (await VisibilityPolicy.CanSee(post, MyId, token))
|
|
visible.Add(post);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
{
|
|
var post = await Visible(id, token);
|
|
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
|
return status == default ? NotFoundError() : Json(status);
|
|
}
|
|
|
|
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Edit(string id, CancellationToken token)
|
|
{
|
|
var outcome = await _statuses.Edit(Me, id, new StatusDraft
|
|
{
|
|
Text = Params.Get("status"),
|
|
PlainText = true,
|
|
SpoilerText = Params.Get("spoiler_text"),
|
|
Sensitive = Params.Bool("sensitive") ?? false,
|
|
Language = Params.Get("language"),
|
|
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default
|
|
}, token);
|
|
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
[HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
|
{
|
|
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
if (before == default)
|
|
return NotFoundError();
|
|
var status = await _mapper.Status(before, MyId, token);
|
|
var outcome = await _statuses.Remove(Me, id, token);
|
|
if (!outcome.Ok)
|
|
return Error(outcome.Status, outcome.Error);
|
|
if (status != default)
|
|
{
|
|
status.Text = before.Text;
|
|
status.SpoilerText = before.SpoilerText ?? string.Empty;
|
|
}
|
|
return Json(status);
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Context(string id, CancellationToken token)
|
|
{
|
|
var post = await Visible(id, token);
|
|
if (post == default)
|
|
return NotFoundError();
|
|
|
|
var ancestors = new List<PostEntity>();
|
|
var cursor = post;
|
|
while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext)
|
|
{
|
|
cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token);
|
|
if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token))
|
|
break;
|
|
ancestors.Insert(0, cursor);
|
|
}
|
|
|
|
var descendants = new List<PostEntity>();
|
|
var frontier = new List<string> { post.ID };
|
|
while (frontier.Count > 0 && descendants.Count < MaxContext)
|
|
{
|
|
var parents = frontier;
|
|
var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
|
.Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token);
|
|
frontier = new List<string>();
|
|
foreach (var child in children)
|
|
if (await VisibilityPolicy.CanSee(child, MyId, token))
|
|
{
|
|
descendants.Add(child);
|
|
frontier.Add(child.ID);
|
|
}
|
|
}
|
|
// a persona opening a public remote thread has its replies read from their servers, for the next look (once an hour)
|
|
if (MyId != default)
|
|
await _jobs.EnqueueMany(new[] { post, ancestors.FirstOrDefault() }
|
|
.Where(p => p is { IsFederatedCopy: true, Visibility: PostVisibility.Public or PostVisibility.Unlisted }).DistinctBy(p => p.ID)
|
|
.Select(p => Federation.Inbox.RepliesJobHandler.For(p, 1)), token);
|
|
return Json(new Context
|
|
{
|
|
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
|
|
Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token)
|
|
});
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> History(string id, CancellationToken token)
|
|
{
|
|
var post = await Visible(id, token);
|
|
if (post == default)
|
|
return NotFoundError();
|
|
var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token);
|
|
var edits = post.Revisions.Select(r => new StatusEdit
|
|
{
|
|
Content = r.ContentHtml ?? string.Empty,
|
|
SpoilerText = r.SpoilerText ?? string.Empty,
|
|
Sensitive = r.HasContentWarning,
|
|
CreatedAt = MastodonJson.Time(r.EditedAt),
|
|
Account = account
|
|
}).ToList();
|
|
edits.Add(new StatusEdit
|
|
{
|
|
Content = MastodonMapper.Content(post),
|
|
SpoilerText = post.SpoilerText ?? string.Empty,
|
|
Sensitive = post.HasContentWarning,
|
|
CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate),
|
|
Account = account
|
|
});
|
|
return Json(edits);
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Source(string id, CancellationToken token)
|
|
{
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
|
|
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
|
|
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Reblog(string id, CancellationToken token)
|
|
{
|
|
var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token);
|
|
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")]
|
|
public Task<IActionResult> Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token);
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> FavouritedBy(string id, CancellationToken token)
|
|
{
|
|
if (await Visible(id, token) == default)
|
|
return NotFoundError();
|
|
var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
|
var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token);
|
|
return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList());
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> RebloggedBy(string id, CancellationToken token)
|
|
{
|
|
if (await Visible(id, token) == default)
|
|
return NotFoundError();
|
|
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
|
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
|
|
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
|
|
}
|
|
|
|
[HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Quotes(string id, CancellationToken token)
|
|
{
|
|
if (await Visible(id, token) == default)
|
|
return NotFoundError();
|
|
var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue);
|
|
var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
|
var visible = new List<PostEntity>();
|
|
foreach (var quote in quotes)
|
|
if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token))
|
|
visible.Add(quote);
|
|
Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
|
|
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
|
|
{
|
|
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
|
|
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
|
|
return NotFoundError();
|
|
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
|
|
}
|
|
|
|
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
|
|
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
|
|
{
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
if (post == default)
|
|
return NotFoundError();
|
|
var policy = Params.Get("quote_approval_policy");
|
|
if (!QuotePolicies.IsKnown(policy))
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
|
|
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
|
|
post.LocalQuotePolicy = policy;
|
|
if (!post.IsLocalOnly)
|
|
await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
|
|
return Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
|
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
|
{
|
|
var post = await Visible(id, token);
|
|
if (post == default)
|
|
return NotFoundError();
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token);
|
|
}
|
|
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
|
{
|
|
}
|
|
return Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
|
|
public async Task<IActionResult> Unbookmark(string id, CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<Models.Social.Bookmark>(b => b.AvatarId == MyId && b.PostId == id);
|
|
return await Unchanged(id, token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
|
|
public async Task<IActionResult> Bookmarks(CancellationToken token)
|
|
{
|
|
var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
|
var ids = bookmarks.Select(b => b.PostId).ToList();
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var visible = new List<PostEntity>();
|
|
foreach (var postId in ids.Where(posts.ContainsKey))
|
|
if (await VisibilityPolicy.CanSee(posts[postId], MyId, token))
|
|
visible.Add(posts[postId]);
|
|
Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")]
|
|
public Task<IActionResult> Mute(string id, CancellationToken token) => Unchanged(id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")]
|
|
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
|
|
public async Task<IActionResult> Pin(string id, CancellationToken token)
|
|
{
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
|
.ExecuteFirstAsync(token);
|
|
if (post == default)
|
|
return await Visible(id, token) is { } other
|
|
? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null
|
|
? "Validation failed: Someone else's post cannot be pinned"
|
|
: "Validation failed: A boost cannot be pinned")
|
|
: NotFoundError();
|
|
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
|
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts");
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
|
|
}
|
|
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
|
{
|
|
}
|
|
return Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
|
public async Task<IActionResult> Unpin(string id, CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
|
return await Unchanged(id, token);
|
|
}
|
|
|
|
async Task<IActionResult> Toggle(Task<StatusOutcome> action, string id, CancellationToken token)
|
|
{
|
|
var outcome = await action;
|
|
if (!outcome.Ok)
|
|
return Error(outcome.Status, outcome.Error);
|
|
var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token);
|
|
return Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
async Task<IActionResult> Unchanged(string id, CancellationToken token)
|
|
{
|
|
var post = await Visible(id, token);
|
|
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
|
return status == default ? NotFoundError() : Json(status);
|
|
}
|
|
|
|
async Task<PostEntity> Visible(string id, CancellationToken token)
|
|
{
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
|
}
|
|
|
|
static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value);
|
|
}
|
|
}
|