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
250 lines
10 KiB
C#
250 lines
10 KiB
C#
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
|
|
};
|
|
}
|
|
}
|