using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.StaticServices; using GroupEntity = PrivaPub.Models.Group.Group; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Federation.Signing { public interface ISignedFetchAuthorizer { Task Requester(HttpRequest request, CancellationToken token); Task MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token); } // Who may refetch a post that is not public (owner decision 2026-10-04). A server that received a followers-only post, // a DM or a circle post refetches it, signed by the account it was for or by its instance actor, and Mastodon deletes its // copy when the refetch answers 404. So the post is served to a signed request from someone it was for, or from the // instance actor of a server where someone it was for lives; everyone else still gets 404. public class SignedFetchAuthorizer : ISignedFetchAuthorizer { readonly IRemoteActorService _remoteActors; readonly DbEntities _dbEntities; public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities) { _remoteActors = remoteActors; _dbEntities = dbEntities; } public async Task Requester(HttpRequest request, CancellationToken token) { var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default) return default; var signingString = HttpSignatures.SigningString(request, parameters); var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature)) return actor; actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default; } public async Task MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token) { if (post == default || requester == default) return false; switch (post.Visibility) { case PostVisibility.Public or PostVisibility.Unlisted: return true; case PostVisibility.Circle: var circle = string.IsNullOrEmpty(post.GroupId) ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token); return circle != default && MayReadCircle(circle, requester); case PostVisibility.Direct: return Addressed(post).Any(uri => Is(requester, uri)); case PostVisibility.FollowersOnly: if (Addressed(post).Any(uri => Is(requester, uri))) return true; var followers = await _dbEntities.Followers .Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted) .ExecuteAsync(token); return followers.Any(f => Is(requester, f.ActorURI)); default: return false; } } static IEnumerable Addressed(PostEntity post) => post.To.Concat(post.Cc).Concat(post.Mentions.Select(m => m.ActorURI)).Where(uri => !string.IsNullOrEmpty(uri)); // the account itself, or the instance actor of the server it lives on static bool Is(ForeignAvatar requester, string actorUri) => actorUri == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(actorUri, requester.ActorURI); public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) => requester != default && circle.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId)); public static bool MayReadConversation(Models.Group.DmGroup conversation, ForeignAvatar requester) => requester != default && conversation.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId)); // the members a refetch names in cc: the requesting member, or the members on an instance actor's server public static IReadOnlyList CircleReaders(GroupEntity circle, ForeignAvatar requester) => requester == default ? Array.Empty() : circle.Members.Where(m => m.IsForeign && Is(requester, m.AvatarId)).Select(m => m.AvatarId).ToList(); } }