Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
564 lines
27 KiB
C#
564 lines
27 KiB
C#
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<PeasantsController> _logger;
|
|
readonly ISignedFetchAuthorizer _fetches;
|
|
readonly IOptionsMonitor<FederationOptions> _federation;
|
|
|
|
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
|
|
ILogger<PeasantsController> logger, ISignedFetchAuthorizer fetches, IOptionsMonitor<FederationOptions> federation)
|
|
{
|
|
_fetches = fetches;
|
|
_federation = federation;
|
|
_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);
|
|
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.IsServerActor)
|
|
return Redirect(local.HtmlUrl);
|
|
return Activity(ActivityPubRenderer.Actor(local));
|
|
}
|
|
|
|
// The instance actor at the server's root, for whoever asks for ActivityPub there: PieFed reads the inbox it sends a
|
|
// community's announces to from the Application at a peer's root, as Lemmy serves its own, and assumes /inbox otherwise
|
|
[HttpGet, Route("/")]
|
|
public async Task<IActionResult> Root(CancellationToken token)
|
|
{
|
|
var accept = Request.Headers.Accept.ToString();
|
|
if (!accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase) && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase))
|
|
return NotFound();
|
|
return Activity(ActivityPubRenderer.Actor(await _localActors.GetInstanceActor(token)));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/anus")]
|
|
public async Task<IActionResult> 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<JsonNode>();
|
|
foreach (var post in latest)
|
|
{
|
|
if (local.Kind == LocalActorKind.Group)
|
|
{
|
|
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate));
|
|
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<IActionResult> 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<Follower>(
|
|
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
|
|
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
|
|
}
|
|
|
|
// a persona's wall (FEP-400e): its own public posts that start a thread, and what its followers wrote on it, newest
|
|
// first, as links; Smithereen reads it when one of its accounts starts following the persona
|
|
[HttpGet, Route("{actor}/" + Walls.Path)]
|
|
public async Task<IActionResult> Wall(string actor, [FromQuery] bool page, [FromQuery(Name = "max_id")] string maxId, CancellationToken token)
|
|
{
|
|
var local = await _localActors.FindByUserName(actor, token);
|
|
if (local is not { HasWall: true })
|
|
return NotFound();
|
|
var wall = local.Wall;
|
|
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly
|
|
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)
|
|
&& (p.WallURI == wall
|
|
|| p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null);
|
|
if (!page)
|
|
return Activity(ActivityPubRenderer.OrderedCollection(wall, (int)await DB.Default.CountAsync(onWall, token), default, $"{wall}?page=true"));
|
|
var query = _dbEntities.Posts.Match(onWall);
|
|
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 pageId = string.IsNullOrEmpty(maxId) ? $"{wall}?page=true" : $"{wall}?page=true&max_id={maxId}";
|
|
var next = latest.Count == OutboxSize ? $"{wall}?page=true&max_id={latest[^1].ID}" : default;
|
|
return Activity(ActivityPubRenderer.OrderedCollectionPage(pageId, wall, latest.Select(p => (JsonNode)(p.ObjectURI ?? local.PostUri(p.ID))), next, default));
|
|
}
|
|
|
|
// FEP-8fcf: the persona's followers on the server that signs the request, and nobody else's (FollowersSynchronization)
|
|
[HttpGet, Route("{actor}/groupies/roll-call")]
|
|
public async Task<IActionResult> RollCall(string actor, CancellationToken token)
|
|
{
|
|
var local = await _localActors.FindByUserName(actor, token);
|
|
if (local is not { IsFederated: true } || !FollowersSynchronization.Synchronizes(local))
|
|
return NotFound();
|
|
var requester = await _fetches.Requester(Request, token);
|
|
if (requester == default)
|
|
return StatusCode(StatusCodes.Status401Unauthorized);
|
|
var there = await FollowersSynchronization.On(local, FollowersSynchronization.Origin(requester.ActorURI), token);
|
|
return Activity(ActivityPubRenderer.OrderedCollection(FollowersSynchronization.RollCall(local), there.Count, there.Select(f => (JsonNode)f)));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/stalking")]
|
|
public async Task<IActionResult> 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<Following>(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<IActionResult> 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<IActionResult> 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<JsonNode>();
|
|
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<IActionResult> 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<Pin>().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<PostEntity>()
|
|
: 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<JsonNode>();
|
|
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<IActionResult> 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<JsonNode>()));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/scribbles/{postId}")]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
// the public and unlisted replies PrivaPub holds to a public post (owner decision 2026-10-06), oldest first
|
|
[HttpGet, Route("{actor}/scribbles/{postId}/replies")]
|
|
public async Task<IActionResult> Replies(string actor, string postId, CancellationToken token)
|
|
{
|
|
var (local, post) = await PublicPost(actor, postId, token);
|
|
if (post == default || post.IsLocalOnly || !string.IsNullOrEmpty(post.GroupId))
|
|
return NotFound();
|
|
var replies = await _dbEntities.Posts.Match(p => p.AnsweringToPostId == post.ID && !p.IsLocalOnly && p.ReblogOfPostId == null)
|
|
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread).ExecuteAsync(token);
|
|
return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count,
|
|
replies.Select(r => (JsonNode)r.ObjectURI)));
|
|
}
|
|
|
|
// a public conversation this post starts (FEP-7888): it and every public or unlisted reply under it PrivaPub holds
|
|
[HttpGet, Route("{actor}/scribbles/{postId}/context")]
|
|
public async Task<IActionResult> Context(string actor, string postId, CancellationToken token)
|
|
{
|
|
var (local, root) = await PublicPost(actor, postId, token);
|
|
if (root == default || root.IsLocalOnly || !string.IsNullOrEmpty(root.GroupId) || root.ContextURI != local.PostUri(root.ID) + "/context")
|
|
return NotFound();
|
|
var thread = new List<PostEntity> { root };
|
|
var level = new List<string> { root.ID };
|
|
for (var depth = 0; depth < PrivaPub.Federation.Inbox.RemotePosts.MaxDepth && level.Count > 0 && thread.Count < MaxThread; depth++)
|
|
{
|
|
var next = await _dbEntities.Posts.Match(p => level.Contains(p.AnsweringToPostId) && !p.IsLocalOnly && p.ReblogOfPostId == null)
|
|
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread - thread.Count).ExecuteAsync(token);
|
|
thread.AddRange(next);
|
|
level = next.Select(p => p.ID).ToList();
|
|
}
|
|
var collection = ActivityPubRenderer.OrderedCollection(root.ContextURI, thread.Count, thread.Select(p => (JsonNode)p.ObjectURI));
|
|
collection["attributedTo"] = local.Uri;
|
|
return Activity(collection);
|
|
}
|
|
|
|
const int MaxThread = 500;
|
|
|
|
[HttpGet, Route("{actor}/parrot-licences/{licenceId}")]
|
|
public async Task<IActionResult> ParrotLicence(string actor, string licenceId, CancellationToken token)
|
|
{
|
|
var author = await _localActors.FindByUserName(actor, token);
|
|
var licence = author == default ? default : await DB.Default.Find<QuoteLicence>().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<IActionResult> 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);
|
|
if (booster is { Kind: LocalActorKind.Group, IsCircle: false })
|
|
return await GroupAnnounce(booster, activityId, uri, token);
|
|
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);
|
|
if (rendered != default && WantsHtml() && await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token) is { } boosted)
|
|
return Redirect(boosted.Url ?? boosted.ObjectURI);//a boost has no page of its own: the boosted post's
|
|
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<IActionResult> 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<IActionResult> 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<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<(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<Models.User.ForeignAvatar>() : 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<IActionResult> 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 };
|
|
}
|
|
|
|
// A community's announce: one GroupDistributor sent (kept as sent), or one its outbox lists (announce-{postId}),
|
|
// while the post it is about is still shown; 410 once that post is gone.
|
|
async Task<IActionResult> GroupAnnounce(LocalActor group, string activityId, string uri, CancellationToken token)
|
|
{
|
|
if (await DB.Default.Find<GroupAnnouncement>().Match(a => a.ActivityURI == uri && a.GroupId == group.Id).ExecuteFirstAsync(token) is { } kept)
|
|
{
|
|
var about = await _dbEntities.Posts.Match(p => p.ObjectURI == kept.ObjectURI).ExecuteFirstAsync(token);
|
|
if (!VisibilityPolicy.Shown(about) || about.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
|
return StatusCode(StatusCodes.Status410Gone);
|
|
var body = JsonNode.Parse(kept.Body)!.AsObject();
|
|
body["@context"] = ActivityPubRenderer.Context();
|
|
return Activity(body);
|
|
}
|
|
var postId = activityId["announce-".Length..];
|
|
var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupId == group.Id).Match(VisibilityPolicy.IsPublic).ExecuteFirstAsync(token);
|
|
return post == default ? NotFound() : Activity(ActivityPubRenderer.Announce(group, post.ObjectURI, activityId, post.CreationDate));
|
|
}
|
|
|
|
async Task<JsonObject> 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<JsonObject> 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 server's own actors (the
|
|
// instance actor, the reporter), whose keys peers need first, and except for browsers, which only get redirected to
|
|
// the public pages
|
|
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml() && Request.Path.Value != "/"
|
|
&& !LocalActorService.IsServerActorName(context.RouteData.Values["actor"] as string)
|
|
&& 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
|
|
};
|
|
}
|
|
}
|