Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
256 lines
12 KiB
C#
256 lines
12 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 { });
|
|
}
|
|
|
|
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
|
|
// `limit` of them, as Mastodon does
|
|
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
|
|
public async Task<IActionResult> UnreadCount(CancellationToken token)
|
|
{
|
|
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
|
|
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
|
return Json(new { count = (await Map(unread, token)).Count });
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|