88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.
Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
with indexed and array fields_attributes (form and JSON), source[*],
quote_policy, locked/bot, avatar and header uploads resized and stripped of
EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
and a circle's id answer 404); search with and without resolve (only a
signed-in persona resolves, the Peer is untouched otherwise), by post and
actor address, hashtags, undiscoverable personas; account statuses with
pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
both ways; followers-only posts for followers (local and remote authors);
community accounts; followers/following only to their owner; follow (open,
locked, remote Follow delivery), unfollow and Undo; follow requests from
local and remote followers answered with the original Follow;
remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
with duration and the notifications choice, never federated; domain blocks;
relationships with junk ids; a banned login's tokens; reports forwarded as a
Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
local and remote posts (mention, inReplyTo, the author's inbox); polls and
votes (local, and remote votes only to the author without published); media
attached only by its owner; quotes, the quotes list and revocation; edit
history, source and the Update delivery; delete for redraft, the 410
Tombstone and the Delete delivery; favourite/reblog counts with Like,
Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
interaction_policy matching canQuote in the note and in the Update;
strangers get 404 for followers-only and direct posts; a located post is
unreachable by id for anyone else on every route; statuses?id[];
Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
remote; tag (anonymous); list stub; favourites; conversations and read;
markers; notifications with types[], exclude_types[], account_id, paging,
get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
unreadable files, video and audio made with ffmpeg lavfi sources and checked
with ffprobe; every remote media address goes through the proxy; the proxy
refuses unsigned URLs, streams ranges as 206 without caching, caches whole
downloads and serves them with ranges, and streams anything over
Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
no signature, the trigger as activity) posts, visibility of provenance,
instance descriptions; reading any of them makes no outbound request.
Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
notifications.
Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
follower. It now sends Reject{Follow} with the stored Follow id, through
RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
so a post in their home timeline answered 404 to GET, context, favourite and
reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
post was gone; it answers 404.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
250 lines
11 KiB
C#
250 lines
11 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
using NotificationEntity = PrivaPub.Models.Social.Notification;
|
|
using MarkerEntity = PrivaPub.Models.Social.Marker;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class TimelinesController : MastodonController
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Home(CancellationToken token)
|
|
{
|
|
var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token);
|
|
var ids = entries.Select(e => e.PostId).ToList();
|
|
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
|
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
|
|
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
|
return Json(statuses);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Public(CancellationToken token)
|
|
{
|
|
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
if (Params.Bool("remote") == true)
|
|
query.Match(p => p.IsFederatedCopy);
|
|
if (Params.Bool("only_media") == true)
|
|
query.Match(p => p.Media.Count > 0);
|
|
return await Respond(query, "/api/v1/timelines/public", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
|
{
|
|
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
|
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
|
if (Params.Bool("local") == true)
|
|
query.Match(p => !p.IsFederatedCopy);
|
|
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
|
}
|
|
|
|
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
|
|
public IActionResult List(string id) => Json(Array.Empty<Status>());
|
|
|
|
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
|
public async Task<IActionResult> Favourites(CancellationToken token)
|
|
{
|
|
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
|
|
var ids = favourites.Select(f => f.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 id in ids.Where(posts.ContainsKey))
|
|
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
|
|
visible.Add(posts[id]);
|
|
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(visible, MyId, token));
|
|
}
|
|
|
|
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Conversations(CancellationToken token)
|
|
{
|
|
var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
|
.Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token);
|
|
var conversations = new List<Conversation>();
|
|
foreach (var group in groups)
|
|
{
|
|
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
|
if (last == default)
|
|
continue;
|
|
var others = new List<string>();
|
|
foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign)))
|
|
others.Add(member.IsForeign
|
|
? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID
|
|
: member.AvatarId);
|
|
var accounts = await _mapper.Accounts(others, token);
|
|
conversations.Add(new Conversation
|
|
{
|
|
Id = group.ID,
|
|
Accounts = accounts.Values.ToList(),
|
|
LastStatus = await _mapper.Status(last, MyId, token)
|
|
});
|
|
}
|
|
return Json(conversations);
|
|
}
|
|
|
|
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
|
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
|
{
|
|
var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token);
|
|
if (group == default)
|
|
return NotFoundError();
|
|
var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token);
|
|
return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) });
|
|
}
|
|
|
|
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
|
public async Task<IActionResult> Markers(CancellationToken token)
|
|
{
|
|
var wanted = Params.List("timeline");
|
|
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
|
|
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
|
|
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
|
|
}
|
|
|
|
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
|
|
public async Task<IActionResult> SaveMarkers(CancellationToken token)
|
|
{
|
|
var result = new Dictionary<string, Entities.Marker>();
|
|
foreach (var timeline in new[] { "home", "notifications" })
|
|
{
|
|
var lastRead = Params.Get($"{timeline}[last_read_id]");
|
|
if (string.IsNullOrEmpty(lastRead))
|
|
continue;
|
|
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
|
|
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
|
|
.Modify(m => m.LastReadId, lastRead)
|
|
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
|
|
.Modify(b => b.Inc(m => m.Version, 1))
|
|
.Option(o => o.IsUpsert = true)
|
|
.ExecuteAsync(token);
|
|
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
|
|
}
|
|
return Json(result);
|
|
}
|
|
|
|
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
|
|
{
|
|
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
|
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
|
return Json(await _mapper.Statuses(posts, MyId, token));
|
|
}
|
|
}
|
|
|
|
public class NotificationsController : MastodonController
|
|
{
|
|
static readonly Dictionary<NotificationType, string> Names = new()
|
|
{
|
|
[NotificationType.Mention] = "mention",
|
|
[NotificationType.Follow] = "follow",
|
|
[NotificationType.FollowRequest] = "follow_request",
|
|
[NotificationType.Favourite] = "favourite",
|
|
[NotificationType.Reblog] = "reblog",
|
|
[NotificationType.Update] = "update",
|
|
[NotificationType.Poll] = "poll",
|
|
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
|
[NotificationType.Quote] = "quote"
|
|
};
|
|
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
|
|
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
|
public async Task<IActionResult> List(CancellationToken token)
|
|
{
|
|
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
|
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
|
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
|
|
if (types.Count > 0)
|
|
query.Match(n => types.Contains(n.Type));
|
|
if (excluded.Count > 0)
|
|
query.Match(n => !excluded.Contains(n.Type));
|
|
if (Params.Get("account_id") is { } accountId)
|
|
query.Match(n => n.FromAccountId == accountId);
|
|
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token);
|
|
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
|
return Json(await Map(notifications, token));
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
{
|
|
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
|
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
|
return mapped == default ? NotFoundError() : Json(mapped);
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Clear(CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
|
|
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
|
{
|
|
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
|
|
return Json(new { });
|
|
}
|
|
|
|
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
|
|
public async Task<IActionResult> UnreadCount(CancellationToken token) =>
|
|
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
|
|
|
|
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
|
|
{
|
|
var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token);
|
|
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
|
var posts = postIds.Count == 0
|
|
? new List<PostEntity>()
|
|
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
|
var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id);
|
|
return notifications
|
|
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
|
.Select(n => new Entities.Notification
|
|
{
|
|
Id = n.ID,
|
|
Type = Names[n.Type],
|
|
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
|
GroupKey = $"ungrouped-{n.ID}",
|
|
Account = accounts[n.FromAccountId],
|
|
Status = n.PostId == default ? default : statuses[n.PostId],
|
|
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
|
EmojiUrl = _mapper.ProxiedUrl(n.EmojiURL)
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
|
}
|
|
}
|