With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
bot, discoverable, indexable, hide collections, posting defaults; the
change federates as Update{Person}), get, lookup, statuses (paged, by
visibility to the viewer), relationships, search, follow and unfollow,
follow requests (authorize and reject answer remote followers with
Accept or Reject), remove from followers. Followers and following lists
are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
delete returning the source for redrafting, context, history, source,
favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.
Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.
Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
277 lines
12 KiB
C#
277 lines
12 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.StaticServices;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class StatusesController : MastodonController
|
|
{
|
|
const int MaxContext = 200;
|
|
|
|
readonly IStatusService _statuses;
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IMemoryCache _cache;
|
|
|
|
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache)
|
|
{
|
|
_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));
|
|
}
|
|
if (Params.List("media_ids").Count > 0)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
|
|
if (Params.Has("poll[options]"))
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet");
|
|
|
|
var outcome = await _statuses.Publish(Me, new StatusDraft
|
|
{
|
|
Text = Params.Get("status"),
|
|
PlainText = true,
|
|
SpoilerText = Params.Get("spoiler_text"),
|
|
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
|
|
Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility),
|
|
InReplyTo = Params.Get("in_reply_to_id"),
|
|
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage
|
|
}, 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));
|
|
}
|
|
|
|
[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);
|
|
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
|
public async Task<IActionResult> Edit(string id, CancellationToken token)
|
|
{
|
|
if (Params.List("media_ids").Count > 0)
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet");
|
|
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")
|
|
}, 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);
|
|
}
|
|
}
|
|
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 && !p.DeletedAt.HasValue).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());
|
|
}
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
|
public Task<IActionResult> Bookmark(string id, CancellationToken token) => Unchanged(id, token);
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
|
|
public Task<IActionResult> Unbookmark(string id, CancellationToken token) => Unchanged(id, 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 IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet");
|
|
|
|
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
|
public Task<IActionResult> Unpin(string id, CancellationToken token) => 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);
|
|
return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token));
|
|
}
|
|
|
|
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) => value?.ToLowerInvariant() switch
|
|
{
|
|
"unlisted" => PostVisibility.Unlisted,
|
|
"private" => PostVisibility.FollowersOnly,
|
|
"direct" => PostVisibility.Direct,
|
|
_ => PostVisibility.Public
|
|
};
|
|
}
|
|
}
|