Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
451 lines
20 KiB
C#
451 lines
20 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.Kind != LocalActorKind.Application)
|
|
return Redirect(local.HtmlUrl);
|
|
return Activity(ActivityPubRenderer.Actor(local));
|
|
}
|
|
|
|
[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)
|
|
{
|
|
var total = local.Kind == LocalActorKind.Group
|
|
? await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupId == local.Id) & f.Where(VisibilityPolicy.IsPublic), token)
|
|
: await DB.Default.CountAsync<PostEntity>(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<JsonNode>();
|
|
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<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));
|
|
}
|
|
|
|
[HttpGet, Route("{actor}/stalking")]
|
|
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, 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);
|
|
}
|
|
|
|
[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);
|
|
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);
|
|
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 };
|
|
}
|
|
|
|
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 instance actor, whose key
|
|
// peers need first, and except for browsers, which only get redirected to the public pages
|
|
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml()
|
|
&& !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} ({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
|
|
};
|
|
}
|
|
}
|