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.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 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) { var total = local.Kind == LocalActorKind.Group ? await DB.Default.CountAsync(f => f.Where(p => p.GroupId == local.Id) & f.Where(VisibilityPolicy.IsPublic), token) : await DB.Default.CountAsync(f => f.Where(p => p.GroupUserId == local.Id && !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), 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(); 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); return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, 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 CirclePost(actor, postId, token) is { } circlePost) { var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI); circleNote["@context"] = ActivityPubRenderer.Context(); return Activity(circleNote); } 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); return post == default ? NotFound() : Activity(await CreateFor(post, local, token)); } [HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)] public async Task Inbox(string actor, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) return NotFound(); 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); } async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); if (local is not { Kind: LocalActorKind.Person }) return default; var post = await _dbEntities.Posts .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle) .ExecuteFirstAsync(token); var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token); if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token))) return default; return (local, _localActors.FromGroup(circle), post); } 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); if (deleted == default) return default; 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 (original == default || original.DeletedAt.HasValue) 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"; if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !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}: {Error}", result.StatusCode, 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 }; } }