- Sign-up, login, the invitation flows and password recovery allow ten requests a minute per client address; the inboxes give each sending origin (the keyId's) a bucket of 300 that refills at 300 a minute, and answer 429 beyond it, which peers retry. - deploy.yml dumps the PrivaPub database to /var/backups before it stops the service (the last seven are kept), and after the swap checks that Swagger answers 404 and that the shared inbox answers junk with 400 and an unsigned activity with 401. - ActivityPubClient and PostBoost, never used, are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
159 lines
6.1 KiB
C#
159 lines
6.1 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
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 IInboxService _inbox;
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILogger<PeasantsController> _logger;
|
|
|
|
public PeasantsController(ILocalActorService localActors, IInboxService inbox, DbEntities dbEntities,
|
|
ILogger<PeasantsController> logger)
|
|
{
|
|
_localActors = localActors;
|
|
_inbox = inbox;
|
|
_dbEntities = dbEntities;
|
|
_logger = logger;
|
|
}
|
|
|
|
[HttpGet, Route("{actor}")]
|
|
public async Task<IActionResult> GetActor(string actor, CancellationToken token)
|
|
{
|
|
var local = await _localActors.FindByUserName(actor, token);
|
|
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.Actor(local));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/anus")]
|
|
public async Task<IActionResult> Outbox(string actor, CancellationToken token)
|
|
{
|
|
var local = await _localActors.FindByUserName(actor, token);
|
|
if (local is not { IsFederated: true })
|
|
return NotFound();
|
|
|
|
var total = local.Kind == LocalActorKind.Group
|
|
? await DB.Default.CountAsync<PostEntity>(p => p.GroupId == local.Id && !p.IsLocalOnly, token)
|
|
: await DB.Default.CountAsync<PostEntity>(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly, token);
|
|
|
|
var latest = await (local.Kind == LocalActorKind.Group
|
|
? _dbEntities.Posts.Match(p => p.GroupId == local.Id && !p.IsLocalOnly)
|
|
: _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly))
|
|
.Sort(p => p.CreationDate, Order.Descending)
|
|
.Limit(OutboxSize)
|
|
.ExecuteAsync(token);
|
|
|
|
var items = new List<JsonNode>();
|
|
foreach (var post in latest)
|
|
{
|
|
if (local.Kind == LocalActorKind.Group)
|
|
{
|
|
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
|
|
continue;
|
|
}
|
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
|
var note = ActivityPubRenderer.Note(post, local, group, default);
|
|
items.Add(ActivityPubRenderer.Create(local, note, $"create-{post.ID}"));
|
|
}
|
|
|
|
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, items));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/followers")]
|
|
public async Task<IActionResult> 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<Follower>(
|
|
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
|
|
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/following")]
|
|
public async Task<IActionResult> 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, Enumerable.Empty<JsonNode>()));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/posts/{postId}")]
|
|
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
|
{
|
|
var local = await _localActors.FindByUserName(actor, token);
|
|
if (local == default || local.Kind != LocalActorKind.Person)
|
|
return NotFound();
|
|
|
|
var post = await _dbEntities.Posts
|
|
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.IsLocalOnly)
|
|
.ExecuteFirstAsync(token);
|
|
if (post == default)
|
|
return NotFound();
|
|
|
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
|
var replyTo = await ReplyTarget(post.AnsweringToPostId, token);
|
|
var note = ActivityPubRenderer.Note(post, local, group, replyTo);
|
|
note["@context"] = ActivityPubRenderer.ActivityStreams;
|
|
return Activity(note);
|
|
}
|
|
|
|
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
|
|
public async Task<IActionResult> 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<IActionResult> ActorSharedInbox(string actor, CancellationToken token) =>
|
|
Answer(await _inbox.Receive(Request, default, token));
|
|
|
|
[HttpPost, Route("/human-centipede"), EnableRateLimiting(RateLimiting.Inbox)]
|
|
public async Task<IActionResult> SharedInbox(CancellationToken token) =>
|
|
Answer(await _inbox.Receive(Request, default, token));
|
|
|
|
async Task<string> ReplyTarget(string answeringToPostId, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(answeringToPostId))
|
|
return default;
|
|
if (answeringToPostId.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|
return answeringToPostId;
|
|
return (await _dbEntities.Posts.MatchID(answeringToPostId).ExecuteFirstAsync(token))?.ObjectURI;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|