Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
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, the reader of feeds), 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
|
|
};
|
|
}
|
|
}
|