Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
517 lines
26 KiB
C#
517 lines
26 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;
|
|
|
|
readonly IParticipations _participations;
|
|
readonly Federation.Actors.IWalls _walls;
|
|
|
|
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
|
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
|
|
Federation.Actors.IWalls walls = default)
|
|
{
|
|
_walls = walls;
|
|
_participations = participations;
|
|
_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
|
|
&& m.TrashedAt == null && m.ProfileOfAvatarId == 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 };
|
|
scheduled.ID = (string)scheduled.GenerateNewID();
|
|
// reserved in one conditional update: a post or another scheduled post taking one meanwhile wins
|
|
if (mediaIds.Count > 0)
|
|
{
|
|
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);
|
|
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,
|
|
MediaChanges = MediaAttributes()
|
|
}, token);
|
|
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
// Mastodon's media_attributes: a JSON body's array of objects, or a form's media_attributes[i][field] or
|
|
// media_attributes[][field] (each field then listed in the same order)
|
|
IReadOnlyList<MediaChange> MediaAttributes()
|
|
{
|
|
var changes = new List<MediaChange>();
|
|
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.TryGetProperty("media_attributes", out var array)
|
|
&& array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
|
{
|
|
foreach (var item in array.EnumerateArray().Where(i => i.ValueKind == System.Text.Json.JsonValueKind.Object).Take(4))
|
|
{
|
|
var hasDescription = item.TryGetProperty("description", out var description);
|
|
changes.Add(new MediaChange(Text(item, "id"), hasDescription, hasDescription ? Text(item, "description") : default, Text(item, "focus")));
|
|
}
|
|
return changes;
|
|
}
|
|
for (var i = 0; i < 4 && Params.Get($"media_attributes[{i}][id]") is { } id; i++)
|
|
changes.Add(new MediaChange(id, Params.Has($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][focus]")));
|
|
var ids = Params.List("media_attributes[][id]");
|
|
var descriptions = Params.List("media_attributes[][description]");
|
|
var foci = Params.List("media_attributes[][focus]");
|
|
for (var i = 0; i < Math.Min(ids.Count, 4); i++)
|
|
changes.Add(new MediaChange(ids[i], i < descriptions.Count, i < descriptions.Count ? descriptions[i] : default, i < foci.Count ? foci[i] : default));
|
|
return changes;
|
|
|
|
static string Text(System.Text.Json.JsonElement item, string name) => item.TryGetProperty(name, out var value)
|
|
? value.ValueKind == System.Text.Json.JsonValueKind.String ? value.GetString() : value.ValueKind is System.Text.Json.JsonValueKind.Null ? default : value.GetRawText()
|
|
: default;
|
|
}
|
|
|
|
[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 await OffWall(id, token);
|
|
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);
|
|
}
|
|
|
|
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
|
|
async Task<IActionResult> OffWall(string id, CancellationToken token)
|
|
{
|
|
var walled = _walls == default
|
|
? default
|
|
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
if (walled == default)
|
|
return NotFoundError();
|
|
var status = await _mapper.Status(walled, MyId, token);
|
|
if (!await _walls.Remove(Me, walled, token))
|
|
return NotFoundError();
|
|
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 });
|
|
}
|
|
|
|
// a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands
|
|
// (privapub.event.participation)
|
|
[HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Join(string id, CancellationToken token)
|
|
{
|
|
var outcome = await _participations.Join(Me, id, token);
|
|
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
[HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Leave(string id, CancellationToken token)
|
|
{
|
|
var outcome = await _participations.Leave(Me, id, token);
|
|
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
|
}
|
|
|
|
[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);
|
|
|
|
// PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote)
|
|
[HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")]
|
|
public Task<IActionResult> Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")]
|
|
public Task<IActionResult> Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(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);
|
|
await _outbox.PublishFeatured(MyId, post, featured: true, 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)
|
|
{
|
|
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
|
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
|
|
await _outbox.PublishFeatured(MyId, post, featured: false, token);
|
|
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);
|
|
}
|
|
}
|