using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; using PrivaPub.Domain.Privacy; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Http; using PrivaPub.Models.Group; using PrivaPub.Infrastructure; namespace PrivaPub.Federation.Controllers { [ApiController, Route("peasants")] public class PeasantsController : ControllerBase, IAsyncActionFilter { const string ActivityContentType = "application/activity+json; charset=utf-8"; const int OutboxSize = 20; readonly ILocalActorService _localActors; readonly IInboxReceiver _inbox; readonly DbEntities _dbEntities; readonly ILogger _logger; readonly ISignedFetchAuthorizer _fetches; readonly IOptionsMonitor _federation; public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities, ILogger logger, ISignedFetchAuthorizer fetches, IOptionsMonitor federation) { _fetches = fetches; _federation = federation; _localActors = localActors; _inbox = inbox; _dbEntities = dbEntities; _logger = logger; } [HttpGet, Route("{actor}")] public async Task GetActor(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local == default && await _localActors.Gone(actor, token) is { } gone) return new ContentResult { Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = gone.Uri, ["type"] = "Tombstone", ["formerType"] = gone.FormerType, ["deleted"] = ActivityPubRenderer.Timestamp(gone.DeletedAt) }.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone }; if (local is not { IsFederated: true }) return NotFound(); if (WantsHtml() && local.Kind != LocalActorKind.Application) return Redirect(local.HtmlUrl); return Activity(ActivityPubRenderer.Actor(local)); } [HttpGet, Route("{actor}/anus")] public async Task Outbox(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) return NotFound(); if (!page) { // as Mastodon's: statuses_count, though only public posts are listed var total = await Counted.PostsBy(local, token); return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, default, $"{local.Outbox}?page=true")); } var query = local.Kind == LocalActorKind.Group ? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsPublic) : _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsPublic); if (!string.IsNullOrEmpty(maxId)) query.Match(f => f.Lt(p => p.ID, maxId)); var latest = await query.Sort(p => p.ID, Order.Descending).Limit(OutboxSize).ExecuteAsync(token); var items = new List(); foreach (var post in latest) { if (local.Kind == LocalActorKind.Group) { items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}")); continue; } if (post.ReblogOfPostId != default) { if (await AnnounceFor(post, token) is { } announce) items.Add(announce); continue; } items.Add(await CreateFor(post, local, token)); } var pageId = string.IsNullOrEmpty(maxId) ? $"{local.Outbox}?page=true" : $"{local.Outbox}?page=true&max_id={maxId}"; var next = latest.Count == OutboxSize ? $"{local.Outbox}?page=true&max_id={latest[^1].ID}" : default; return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, local.Outbox, items, next, default)); } [HttpGet, Route("{actor}/groupies")] public async Task Followers(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) return NotFound(); if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token), await _fetches.Requester(Request, token))) return NotFound(); var count = await DB.Default.CountAsync( f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token); return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default)); } [HttpGet, Route("{actor}/stalking")] public async Task Following(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) return NotFound(); // the count, as following_count says it; who is followed is never listed (FEDERATION.md) var following = local.Kind == LocalActorKind.Person ? await DB.Default.CountAsync(f => f.AvatarId == local.Id && f.State == FollowState.Accepted, token) : 0; return Activity(ActivityPubRenderer.OrderedCollection(local.Following, (int)following, default)); } [HttpGet, Route("{actor}/flock")] public async Task Members(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Group }) return NotFound(); var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token); if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token))) return NotFound(); return Activity(ActivityPubRenderer.OrderedCollection(local.Flock, group.Members.Count, default)); } [HttpGet, Route("{actor}/wardens")] public async Task Moderators(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Group }) return NotFound(); var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token); if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token))) return NotFound(); var moderators = new List(); foreach (var member in group.Members.Where(m => !m.IsForeign && m.Role is GroupRole.Owner or GroupRole.Moderator)) if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } moderator) moderators.Add(moderator.Uri); return Activity(ActivityPubRenderer.OrderedCollection(local.Wardens, moderators.Count, moderators)); } [HttpGet, Route("{actor}/trophies")] public async Task Featured(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) return NotFound(); var pinIds = (await DB.Default.Find().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)) .Select(p => p.PostId).ToList(); var posts = pinIds.Count == 0 ? new List() : await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && p.GroupUserId == local.Id && p.ReblogOfPostId == null) .Match(VisibilityPolicy.IsPublic).ExecuteAsync(token); var notes = new List(); foreach (var id in pinIds) if (posts.FirstOrDefault(p => p.ID == id) is { } post) notes.Add(ActivityPubRenderer.Note(post, local, default, post.InReplyToURI)); return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes)); } [HttpGet, Route("{actor}/tattoos")] public async Task FeaturedTags(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty())); } [HttpGet, Route("{actor}/scribbles/{postId}")] public async Task Post(string actor, string postId, CancellationToken token) { var (local, post) = await PublicPost(actor, postId, token); if (post == default && await SignedPost(actor, postId, token) is { } signed) { if (signed.Post.DeletedAt.HasValue) return TombstoneOf(signed.Author, signed.Post); var signedNote = (JsonObject)signed.Note.DeepClone(); signedNote["@context"] = ActivityPubRenderer.Context(); return Activity(signedNote); } if (post == default) return await Tombstone(actor, postId, token) ?? NotFound(); if (WantsHtml()) return Redirect(local.PostHtmlUrl(post.ID)); var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); var note = ActivityPubRenderer.Note(post, local, group, post.InReplyToURI); note["@context"] = ActivityPubRenderer.Context(); return Activity(note); } [HttpGet, Route("{actor}/parrot-licences/{licenceId}")] public async Task ParrotLicence(string actor, string licenceId, CancellationToken token) { var author = await _localActors.FindByUserName(actor, token); var licence = author == default ? default : await DB.Default.Find().Match(l => l.ID == licenceId && l.AuthorAvatarId == author.Id).ExecuteFirstAsync(token); var quoted = licence == default ? default : await _dbEntities.Posts.MatchID(licence.PostId).ExecuteFirstAsync(token); if (quoted == default) return NotFound(); var uri = Domain.Statuses.QuoteService.LicenceUri(author, licence.ID); if (licence.RevokedAt.HasValue || quoted.DeletedAt.HasValue) return new ContentResult { Content = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "Tombstone", ["formerType"] = "QuoteAuthorization" }.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone }; return Activity(new JsonObject { ["@context"] = ActivityPubRenderer.Context(), ["id"] = uri, ["type"] = "QuoteAuthorization", ["attributedTo"] = author.Uri, ["interactingObject"] = licence.QuotingObjectURI, ["interactionTarget"] = quoted.ObjectURI }); } [HttpGet, Route("{actor}/grunts/{activityId}")] public async Task Grunt(string actor, string activityId, CancellationToken token) { if (activityId.StartsWith("announce-", StringComparison.Ordinal)) { var booster = await _localActors.FindByUserName(actor, token); var uri = booster?.ActivityUri(activityId); var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var rendered = reblog == default ? default : await AnnounceFor(reblog, token); return rendered == default ? NotFound() : Activity(rendered); } if (!activityId.StartsWith("create-", StringComparison.Ordinal)) return NotFound(); var (local, post) = await PublicPost(actor, activityId["create-".Length..], token); if (post != default) return Activity(await CreateFor(post, local, token)); return await SignedPost(actor, activityId["create-".Length..], token) is { Post.DeletedAt: null } signed ? Activity(ActivityPubRenderer.Create(signed.Author, (JsonObject)signed.Note.DeepClone(), activityId)) : NotFound(); } // A DM's `context`: the conversation's posts, for its participants (or their servers' instance actors) only. [HttpGet, Route("{actor}/whispers/{conversationId}")] public async Task Whispers(string actor, string conversationId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) return NotFound(); var uri = local.ConversationUri(conversationId); var conversation = await _dbEntities.DmGroups.Match(g => g.ID == conversationId && g.ConversationURI == uri && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); if (conversation == default || !SignedFetchAuthorizer.MayReadConversation(conversation, await _fetches.Requester(Request, token))) return NotFound(); var posts = await _dbEntities.Posts .Match(p => p.ConversationId == conversationId && p.Visibility == PostVisibility.Direct && !p.DeletedAt.HasValue) .Sort(p => p.CreationDate, Order.Ascending) .ExecuteAsync(token); return Activity(new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = uri, ["type"] = "OrderedCollection", ["totalItems"] = posts.Count, ["orderedItems"] = new JsonArray(posts.Select(p => (JsonNode)p.ObjectURI).ToArray()) }); } [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)] public async Task Inbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local == default && await _localActors.Gone(actor, token) != default) return StatusCode(StatusCodes.Status410Gone); if (local is not { IsFederated: true }) return Answer(_inbox.NoSuchRecipient(Request)); return Answer(await _inbox.Receive(Request, local, token)); } [HttpPost, Route("{actor}/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task ActorSharedInbox(string actor, CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); [HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)] public async Task SharedInbox(CancellationToken token) => Answer(await _inbox.Receive(Request, default, token)); async Task<(LocalActor Actor, PostEntity Post)> PublicPost(string actor, string postId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) return default; var post = await _dbEntities.Posts .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null) .Match(VisibilityPolicy.IsPublic) .ExecuteFirstAsync(token); return (local, post); } // A followers-only, direct or circle post (deleted ones included), for a signed request from someone it was for // (SignedFetchAuthorizer.MayRead). It is rendered as it was delivered: a circle post also names, in cc, the requesting // member, or the members on the requesting instance actor's server. async Task<(LocalActor Author, PostEntity Post, JsonObject Note)?> SignedPost(string actor, string postId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) return default; var post = await _dbEntities.Posts .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && (p.Visibility == PostVisibility.FollowersOnly || p.Visibility == PostVisibility.Direct || p.Visibility == PostVisibility.Circle)) .ExecuteFirstAsync(token); if (post == default) return default; var requester = await _fetches.Requester(Request, token); if (!await _fetches.MayRead(post, requester, token)) return default; if (post.Visibility == PostVisibility.Circle) { var circle = await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token); var note = ActivityPubRenderer.Note(post, local, _localActors.FromGroup(circle), post.InReplyToURI); var readers = SignedFetchAuthorizer.CircleReaders(circle, requester); var named = readers.Count == 0 ? new List() : await _dbEntities.ForeignAvatars.Match(a => readers.Contains(a.ActorURI)).ExecuteAsync(token); return (local, post, OutboxPublisher.Naming(note, named)); } var rendered = post.Visibility == PostVisibility.Direct ? ActivityPubRenderer.DirectNote(post, local, Array.Empty<(string, string)>(), post.ContextURI) : ActivityPubRenderer.Note(post, local, default, post.InReplyToURI); rendered["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()); rendered["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray()); return (local, post, rendered); } async Task Tombstone(string actor, string postId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true, Kind: LocalActorKind.Person }) return default; var deleted = await _dbEntities.Posts .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)) .ExecuteFirstAsync(token); return deleted == default ? default : TombstoneOf(local, deleted); } ContentResult TombstoneOf(LocalActor local, PostEntity deleted) { var tombstone = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = local.PostUri(deleted.ID), ["type"] = "Tombstone", ["formerType"] = "Note", ["deleted"] = ActivityPubRenderer.Timestamp(deleted.DeletedAt.Value) }; return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone }; } async Task AnnounceFor(PostEntity reblog, CancellationToken token) { var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token); if (!VisibilityPolicy.Shown(original)) return default; return new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, ["id"] = reblog.ObjectURI, ["type"] = "Announce", ["actor"] = reblog.ActorURI, ["published"] = ActivityPubRenderer.Timestamp(reblog.CreationDate), ["to"] = new JsonArray(reblog.To.Select(t => (JsonNode)t).ToArray()), ["cc"] = new JsonArray(reblog.Cc.Select(c => (JsonNode)c).ToArray()), ["object"] = original.ObjectURI }; } async Task CreateFor(PostEntity post, LocalActor author, CancellationToken token) { var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); var note = ActivityPubRenderer.Note(post, author, group, post.InReplyToURI); return ActivityPubRenderer.Create(author, note, $"create-{post.ID}"); } bool WantsHtml() { var accept = Request.Headers.Accept.ToString(); return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase) && !accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase); } [NonAction] public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { if (HttpMethods.IsGet(Request.Method)) Response.Headers.Vary = "Accept"; // SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key // peers need first, and except for browsers, which only get redirected to the public pages if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase) && await _fetches.Requester(Request, HttpContext.RequestAborted) == default) { context.Result = StatusCode(StatusCodes.Status401Unauthorized); return; } await next(); } IActionResult Answer(InboxResult result) { if (result.Error != default) _logger.LogInformation("Inbox refused with {Status} ({Reason}): {Error}", result.StatusCode, result.Reason, result.Error); if (result.RetryAfterSeconds is { } seconds) Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); } ContentResult Activity(JsonObject document) => new() { Content = document.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status200OK }; } }