Mastodon clients can sign in: OAuth with a persona per token
OpenIddict 7.7 (MongoDB stores, keys kept in Mongo so tokens survive
restarts) serves /oauth/authorize, /oauth/token, /oauth/revoke and the
discovery documents, including /.well-known/oauth-authorization-server:
- authorization code (PKCE optional) and client credentials, Mastodon's
scopes including the granular ones, non-expiring reference tokens,
`created_at` in the token response, and the urn:ietf:wg:oauth:2.0:oob
page that shows the code;
- /oauth/login signs the private login into a fifteen-minute cookie that
only /oauth sees (rate limited, antiforgery-protected); /oauth/authorize
then asks which persona the application acts as. The token's subject
is that persona's id and nothing else; no root id reaches a token, an
authorization or a log line;
- the token exchange refuses a persona whose login is banned or deleted,
and every API request checks the same.
/api/v1/apps registers applications dynamically, /api/v1/apps/
verify_credentials, /api/v1/instance (v1 and v2, "4.2.0 (compatible;
PrivaPub)") and verify_credentials answer in Mastodon's shapes: snake_case
with explicit nulls, Rails-style parameters from query, form or JSON,
{"error": ...} on failure, Link paging. CORS exposes Link.
/api goes to OpenIddict validation and everything else to the existing
JWT; the JWT failure handler no longer sends the exception and stack trace
to the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
e6a6fe46d7
commit
4d9be37c1c
22 files changed
+1394
-7
No files matched your search
@@ -0,0 +1,249 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Mappers
|
||||
{
|
||||
public class MastodonMapper
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||
|
||||
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
|
||||
{
|
||||
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
|
||||
var following = actor.Kind == LocalActorKind.Person
|
||||
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
||||
: 0;
|
||||
var latest = await _dbEntities.Posts
|
||||
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
|
||||
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
|
||||
.Sort(p => p.ID, Order.Descending)
|
||||
.Limit(1)
|
||||
.ExecuteFirstAsync(token);
|
||||
var statuses = latest == default
|
||||
? 0
|
||||
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
|
||||
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Id = actor.Id,
|
||||
Username = actor.UserName,
|
||||
Acct = actor.UserName,
|
||||
DisplayName = actor.Name ?? actor.UserName,
|
||||
Locked = actor.ManuallyApprovesFollowers,
|
||||
Discoverable = actor.Discoverable,
|
||||
Group = actor.Kind == LocalActorKind.Group,
|
||||
CreatedAt = MastodonJson.Day(actor.Published),
|
||||
Note = ActivityPubRenderer.Html(actor.Summary),
|
||||
Url = actor.HtmlUrl,
|
||||
Uri = actor.Uri,
|
||||
Avatar = actor.PictureURL ?? MissingAvatar,
|
||||
AvatarStatic = actor.PictureURL ?? MissingAvatar,
|
||||
Header = actor.ThumbnailURL ?? MissingHeader,
|
||||
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
|
||||
FollowersCount = (int)followers,
|
||||
FollowingCount = (int)following,
|
||||
StatusesCount = (int)statuses,
|
||||
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
||||
};
|
||||
if (withSource)
|
||||
account.Source = new Source
|
||||
{
|
||||
Note = actor.Summary ?? string.Empty,
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||
};
|
||||
return account;
|
||||
}
|
||||
|
||||
public Account Foreign(ForeignAvatar foreign) => new()
|
||||
{
|
||||
Id = foreign.ID,
|
||||
Username = foreign.UserName,
|
||||
Acct = $"{foreign.UserName}@{foreign.Domain}",
|
||||
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||||
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||||
Group = foreign.AvatarType == AvatarType.Group,
|
||||
Discoverable = foreign.IsDiscoverable,
|
||||
CreatedAt = MastodonJson.Day(foreign.CreatedAt),
|
||||
Note = foreign.Biography ?? string.Empty,
|
||||
Url = foreign.Url ?? foreign.ActorURI,
|
||||
Uri = foreign.ActorURI,
|
||||
Avatar = foreign.PictureURL ?? MissingAvatar,
|
||||
AvatarStatic = foreign.PictureURL ?? MissingAvatar,
|
||||
Header = foreign.ThumbnailURL ?? MissingHeader,
|
||||
HeaderStatic = foreign.ThumbnailURL ?? MissingHeader
|
||||
};
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
||||
|
||||
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
|
||||
{
|
||||
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||||
var accounts = new Dictionary<string, Account>();
|
||||
if (wanted.Count == 0)
|
||||
return accounts;
|
||||
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
||||
accounts[foreign.ID] = Foreign(foreign);
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
||||
(await Statuses(new[] { post }, viewerId, token)).FirstOrDefault();
|
||||
|
||||
public async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token)
|
||||
{
|
||||
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
||||
var originals = originalIds.Count == 0
|
||||
? new Dictionary<string, PostEntity>()
|
||||
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var all = posts.Concat(originals.Values).ToList();
|
||||
|
||||
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
||||
var mentionAccounts = mentionUris.Count == 0
|
||||
? new Dictionary<string, ForeignAvatar>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||||
var accounts = await Accounts(all.Select(AuthorOf), token);
|
||||
|
||||
var ids = all.Select(p => p.ID).ToList();
|
||||
var favourited = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||
var reblogged = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
.Select(p => p.ReblogOfPostId).ToHashSet();
|
||||
|
||||
Status Map(PostEntity post)
|
||||
{
|
||||
if (!accounts.TryGetValue(AuthorOf(post), out var account))
|
||||
return default;
|
||||
var status = new Status
|
||||
{
|
||||
Id = post.ID,
|
||||
Uri = post.ObjectURI,
|
||||
Url = post.Url ?? post.ObjectURI,
|
||||
Account = account,
|
||||
InReplyToId = post.AnsweringToPostId,
|
||||
InReplyToAccountId = post.InReplyToAccountId,
|
||||
Content = Content(post),
|
||||
CreatedAt = MastodonJson.Time(post.CreationDate),
|
||||
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
|
||||
RepliesCount = post.RepliesCount,
|
||||
ReblogsCount = post.ReblogsCount,
|
||||
FavouritesCount = post.FavouritesCount,
|
||||
Favourited = favourited.Contains(post.ID),
|
||||
Reblogged = reblogged.Contains(post.ID),
|
||||
Sensitive = post.HasContentWarning,
|
||||
SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||
Visibility = Visibility(post.Visibility),
|
||||
Language = post.Language,
|
||||
MediaAttachments = post.Media.Select(Media).ToList(),
|
||||
Mentions = post.Mentions.Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList()
|
||||
};
|
||||
return status;
|
||||
}
|
||||
|
||||
var mapped = new List<Status>();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
var status = Map(post);
|
||||
if (status == default)
|
||||
continue;
|
||||
if (post.ReblogOfPostId != default)
|
||||
{
|
||||
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
||||
continue;
|
||||
status.Reblog = inner;
|
||||
status.Content = string.Empty;
|
||||
status.Reblogged = reblogged.Contains(original.ID);
|
||||
}
|
||||
mapped.Add(status);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
||||
|
||||
public static string Content(PostEntity post)
|
||||
{
|
||||
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||||
return string.IsNullOrEmpty(post.Title) || post.IsFederatedCopy
|
||||
? content ?? string.Empty
|
||||
: $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
||||
}
|
||||
|
||||
public static string Visibility(PostVisibility visibility) => visibility switch
|
||||
{
|
||||
PostVisibility.Unlisted => "unlisted",
|
||||
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
|
||||
PostVisibility.Direct => "direct",
|
||||
_ => "public"
|
||||
};
|
||||
|
||||
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
|
||||
{
|
||||
if (mention.IsLocal)
|
||||
{
|
||||
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
|
||||
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
|
||||
}
|
||||
if (!foreign.TryGetValue(mention.ActorURI, out var account))
|
||||
return default;
|
||||
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||||
}
|
||||
|
||||
static MediaAttachment Media(PostMedia media) => new()
|
||||
{
|
||||
Id = media.Id.ToString("N"),
|
||||
Type = media.ContentType switch
|
||||
{
|
||||
{ } type when type.StartsWith("image/gif") => "gifv",
|
||||
{ } type when type.StartsWith("image/") => "image",
|
||||
{ } type when type.StartsWith("video/") => "video",
|
||||
{ } type when type.StartsWith("audio/") => "audio",
|
||||
_ => "unknown"
|
||||
},
|
||||
Url = media.URL ?? media.RemoteURL,
|
||||
PreviewUrl = media.URL ?? media.RemoteURL,
|
||||
RemoteUrl = media.RemoteURL,
|
||||
Description = media.Description,
|
||||
Blurhash = media.Blurhash,
|
||||
Meta = media.Width.HasValue && media.Height.HasValue
|
||||
? new { original = new { width = media.Width, height = media.Height, aspect = (double)media.Width / media.Height.Value } }
|
||||
: default
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user