2026-10-01 12:30:57 +02:00
|
|
|
using PrivaPub.Federation.Actors;
|
|
|
|
|
using PrivaPub.Federation.Objects;
|
2026-10-04 03:01:14 +02:00
|
|
|
using PrivaPub.Models.Federation;
|
|
|
|
|
using PrivaPub.Models.Post;
|
2026-10-01 12:30:57 +02:00
|
|
|
using PrivaPub.Models.User;
|
2026-10-04 03:01:14 +02:00
|
|
|
using PrivaPub.StaticServices;
|
2026-10-01 12:30:57 +02:00
|
|
|
|
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
2026-10-04 03:01:14 +02:00
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
2026-10-01 12:30:57 +02:00
|
|
|
|
|
|
|
|
namespace PrivaPub.Federation.Signing
|
|
|
|
|
{
|
|
|
|
|
public interface ISignedFetchAuthorizer
|
|
|
|
|
{
|
|
|
|
|
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
|
2026-10-04 03:01:14 +02:00
|
|
|
Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token);
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:01:14 +02:00
|
|
|
// 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.
|
2026-10-01 12:30:57 +02:00
|
|
|
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
|
|
|
|
|
{
|
|
|
|
|
readonly IRemoteActorService _remoteActors;
|
2026-10-04 03:01:14 +02:00
|
|
|
readonly DbEntities _dbEntities;
|
2026-10-01 12:30:57 +02:00
|
|
|
|
2026-10-04 03:01:14 +02:00
|
|
|
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities)
|
2026-10-01 12:30:57 +02:00
|
|
|
{
|
|
|
|
|
_remoteActors = remoteActors;
|
2026-10-04 03:01:14 +02:00
|
|
|
_dbEntities = dbEntities;
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<ForeignAvatar> 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:01:14 +02:00
|
|
|
public async Task<bool> 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<string> 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);
|
|
|
|
|
|
2026-10-01 12:30:57 +02:00
|
|
|
public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) =>
|
2026-10-04 03:01:14 +02:00
|
|
|
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<string> CircleReaders(GroupEntity circle, ForeignAvatar requester) =>
|
|
|
|
|
requester == default
|
|
|
|
|
? Array.Empty<string>()
|
|
|
|
|
: circle.Members.Where(m => m.IsForeign && Is(requester, m.AvatarId)).Select(m => m.AvatarId).ToList();
|
2026-10-01 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
}
|