using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; 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.Infrastructure; namespace PrivaPub.Federation.Controllers { [ApiController, Route("peasants")] public class PeasantsController : ControllerBase { 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; public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities, ILogger logger) { _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; } 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}/scribbles/{postId}")] public async Task Post(string actor, string postId, CancellationToken token) { var (local, post) = await PublicPost(actor, postId, token); if (post == default) return 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}/grunts/{activityId}")] public async Task Grunt(string actor, string activityId, CancellationToken token) { 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) .Match(VisibilityPolicy.IsPublic) .ExecuteFirstAsync(token); return (local, post); } 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); } IActionResult Answer(InboxResult result) { if (result.Error != default) _logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); 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 }; } }