2026-10-07 10:48:44 +02:00
|
|
|
using Microsoft.AspNetCore.RateLimiting;
|
2026-10-01 11:52:31 +02:00
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
2026-10-01 11:52:31 +02:00
|
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
|
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
2026-10-01 12:22:08 +02:00
|
|
|
using PrivaPub.Domain.Media;
|
2026-10-01 12:05:58 +02:00
|
|
|
using PrivaPub.Domain.Privacy;
|
2026-10-01 12:13:57 +02:00
|
|
|
using PrivaPub.Domain.Relationships;
|
2026-10-01 12:05:58 +02:00
|
|
|
using PrivaPub.Domain.Social;
|
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
|
|
|
using PrivaPub.Federation.Outbox;
|
|
|
|
|
using PrivaPub.Models.Federation;
|
|
|
|
|
using PrivaPub.Models.Post;
|
|
|
|
|
using PrivaPub.Models.Social;
|
|
|
|
|
using PrivaPub.Models.User;
|
|
|
|
|
using PrivaPub.StaticServices;
|
2026-10-07 10:48:44 +02:00
|
|
|
using PrivaPub.Infrastructure;
|
2026-10-01 12:05:58 +02:00
|
|
|
|
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
2026-10-01 11:52:31 +02:00
|
|
|
|
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
|
|
|
{
|
2026-10-01 12:05:58 +02:00
|
|
|
public class AccountsController : MastodonController
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
|
|
|
|
readonly MastodonMapper _mapper;
|
2026-10-01 12:05:58 +02:00
|
|
|
readonly DbEntities _dbEntities;
|
|
|
|
|
readonly ILocalActorService _localActors;
|
|
|
|
|
readonly IRemoteActorService _remoteActors;
|
|
|
|
|
readonly IFollowService _follows;
|
|
|
|
|
readonly IOutboxPublisher _outbox;
|
2026-10-01 12:13:57 +02:00
|
|
|
readonly IRelationshipService _relationships;
|
2026-10-08 02:15:28 +02:00
|
|
|
readonly IFeedFollows _feeds;
|
2026-10-01 11:52:31 +02:00
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
2026-10-08 02:15:28 +02:00
|
|
|
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
|
2026-10-01 11:52:31 +02:00
|
|
|
{
|
2026-10-08 02:15:28 +02:00
|
|
|
_feeds = feeds;
|
2026-10-01 12:13:57 +02:00
|
|
|
_relationships = relationships;
|
2026-10-01 11:52:31 +02:00
|
|
|
_mapper = mapper;
|
2026-10-01 12:05:58 +02:00
|
|
|
_dbEntities = dbEntities;
|
|
|
|
|
_localActors = localActors;
|
|
|
|
|
_remoteActors = remoteActors;
|
|
|
|
|
_follows = follows;
|
|
|
|
|
_outbox = outbox;
|
2026-10-01 11:52:31 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-18 09:18:26 +02:00
|
|
|
// Mastodon 4.4: a token with the profile scope alone may read it too
|
|
|
|
|
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts|profile")]
|
2026-10-01 11:52:31 +02:00
|
|
|
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
2026-10-01 12:05:58 +02:00
|
|
|
|
2026-10-07 10:48:44 +02:00
|
|
|
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
|
2026-10-01 12:22:08 +02:00
|
|
|
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
|
|
|
|
|
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
2026-10-01 12:05:58 +02:00
|
|
|
{
|
|
|
|
|
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
2026-10-07 10:31:14 +02:00
|
|
|
// a new picture is a row of its own; the one it replaces goes to the trash once the profile is saved
|
|
|
|
|
var replaced = new List<Models.Media.MediaAttachment>();
|
2026-10-01 12:22:08 +02:00
|
|
|
if (Request.HasFormContentType)
|
|
|
|
|
{
|
|
|
|
|
var form = await Request.ReadFormAsync(token);
|
2026-10-07 10:31:14 +02:00
|
|
|
foreach (var (field, kind, width, height) in new[] { ("avatar", "avatar", 400, 400), ("header", "header", 1500, 500) })
|
|
|
|
|
{
|
|
|
|
|
if (form.Files[field] is not { } file)
|
|
|
|
|
continue;
|
|
|
|
|
var outcome = await media.ProfileImage(avatar.ID, kind, file, width, height, token);
|
|
|
|
|
if (!outcome.Ok)
|
|
|
|
|
{
|
|
|
|
|
foreach (var made in replaced)
|
|
|
|
|
await media.Trash(m => m.ID == made.ID, "profile not saved", token);
|
|
|
|
|
return Error(outcome.Status, outcome.Error);
|
|
|
|
|
}
|
|
|
|
|
replaced.Add(outcome.Attachment);
|
|
|
|
|
if (kind == "avatar")
|
|
|
|
|
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
|
|
|
|
|
else
|
|
|
|
|
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
|
|
|
|
|
}
|
2026-10-01 12:22:08 +02:00
|
|
|
}
|
2026-10-01 12:05:58 +02:00
|
|
|
if (Params.Has("display_name"))
|
|
|
|
|
avatar.Name = Params.Get("display_name")?.Trim();
|
|
|
|
|
if (Params.Has("note"))
|
|
|
|
|
avatar.Biography = Params.Get("note");
|
|
|
|
|
if (Params.Bool("locked") is { } locked)
|
|
|
|
|
avatar.Settings.IsLocked = locked;
|
|
|
|
|
if (Params.Bool("bot") is { } bot)
|
|
|
|
|
avatar.Settings.IsBot = bot;
|
|
|
|
|
if (Params.Bool("discoverable") is { } discoverable)
|
|
|
|
|
avatar.Settings.IsDiscoverable = discoverable;
|
|
|
|
|
if (Params.Bool("indexable") is { } indexable)
|
|
|
|
|
avatar.Settings.IsIndexable = indexable;
|
2026-10-04 03:48:32 +02:00
|
|
|
// hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
|
2026-10-01 12:05:58 +02:00
|
|
|
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
|
|
|
|
|
avatar.Settings.DefaultVisibility = privacy;
|
|
|
|
|
if (Params.Bool("source[sensitive]") is { } sensitive)
|
|
|
|
|
avatar.Settings.DefaultSensitive = sensitive;
|
|
|
|
|
if (Params.Has("source[language]"))
|
|
|
|
|
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
2026-10-01 19:53:13 +02:00
|
|
|
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
|
|
|
|
avatar.Settings.QuotePolicy = quotePolicy;
|
2026-10-01 12:05:58 +02:00
|
|
|
var fields = new Dictionary<string, string>();
|
|
|
|
|
for (var i = 0; i < 4; i++)
|
|
|
|
|
{
|
|
|
|
|
var name = Params.Get($"fields_attributes[{i}][name]") ?? Params.Get($"fields_attributes[][name]");
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
|
|
|
fields[name.Trim()] = Params.Get($"fields_attributes[{i}][value]")?.Trim() ?? string.Empty;
|
|
|
|
|
}
|
|
|
|
|
if (Params.List("fields_attributes[][name]").Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var names = Params.List("fields_attributes[][name]");
|
|
|
|
|
var values = Params.List("fields_attributes[][value]");
|
|
|
|
|
fields = names.Select((n, i) => (n, v: i < values.Count ? values[i] : string.Empty)).Where(f => !string.IsNullOrWhiteSpace(f.n))
|
|
|
|
|
.Take(4).ToDictionary(f => f.n.Trim(), f => f.v.Trim());
|
|
|
|
|
}
|
|
|
|
|
if (fields.Count > 0 || Params.Has("fields_attributes[0][name]"))
|
|
|
|
|
avatar.Fields = fields;
|
2026-10-17 05:57:25 +02:00
|
|
|
avatar.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(avatar);//the server's custom emoji its name, note and fields use (R7)
|
2026-10-01 12:05:58 +02:00
|
|
|
avatar.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
await DB.Default.SaveAsync(avatar, token);
|
2026-10-07 10:31:14 +02:00
|
|
|
foreach (var made in replaced)
|
|
|
|
|
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == made.Kind && m.ID != made.ID, "replaced", token);
|
2026-10-01 12:05:58 +02:00
|
|
|
|
|
|
|
|
var actor = _localActors.FromAvatar(avatar);
|
|
|
|
|
await _outbox.PublishProfile(actor, token);
|
|
|
|
|
return Json(await _mapper.Local(actor, withSource: true, token));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 10:55:12 +02:00
|
|
|
// Mastodon's: the picture goes (to the trash), the profile shows the default again, and followers are told
|
|
|
|
|
[HttpDelete("/api/v1/profile/avatar"), Scope("write:accounts")]
|
|
|
|
|
public Task<IActionResult> DeleteAvatar([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "avatar", token);
|
|
|
|
|
|
|
|
|
|
[HttpDelete("/api/v1/profile/header"), Scope("write:accounts")]
|
|
|
|
|
public Task<IActionResult> DeleteHeader([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "header", token);
|
|
|
|
|
|
|
|
|
|
async Task<IActionResult> DeletePicture(IMediaService media, string kind, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
|
|
|
|
if (kind == "avatar")
|
|
|
|
|
avatar.PictureURL = default;
|
|
|
|
|
else
|
|
|
|
|
avatar.ThumbnailURL = default;
|
|
|
|
|
avatar.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
await DB.Default.SaveAsync(avatar, token);
|
|
|
|
|
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind, "removed", token);
|
|
|
|
|
var actor = _localActors.FromAvatar(avatar);
|
|
|
|
|
await _outbox.PublishProfile(actor, token);
|
|
|
|
|
return Json(await _mapper.Local(actor, withSource: true, token));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
[HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Lookup(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var acct = Params.Get("acct")?.Trim().TrimStart('@');
|
|
|
|
|
var parts = acct?.Split('@');
|
|
|
|
|
if (parts is not { Length: 1 or 2 } || string.IsNullOrEmpty(parts[0]))
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
|
|
|
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
var local = await _localActors.FindByUserName(parts[0], token);
|
2026-10-17 05:23:45 +02:00
|
|
|
return local is { IsFederated: true, IsCircle: false, IsServerActor: false, Suspended: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
2026-10-01 12:05:58 +02:00
|
|
|
}
|
|
|
|
|
var userName = parts[0];
|
|
|
|
|
var domain = parts[1].ToLowerInvariant();
|
2026-10-17 05:23:45 +02:00
|
|
|
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
|
|
|
|
.ExecuteFirstAsync(token);
|
2026-10-01 12:05:58 +02:00
|
|
|
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/accounts/relationships"), Scope("read:follows")]
|
|
|
|
|
public async Task<IActionResult> Relationships(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var relationships = new List<Relationship>();
|
|
|
|
|
foreach (var id in Params.List("id").Distinct().Take(40))
|
|
|
|
|
relationships.Add(await Relationship(id, token));
|
|
|
|
|
return Json(relationships);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
|
|
|
|
|
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
|
2026-10-05 02:40:40 +02:00
|
|
|
Json(Params.Bool("following") == true && MyId != default
|
|
|
|
|
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
|
|
|
|
|
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
2026-10-01 12:05:58 +02:00
|
|
|
|
2026-10-17 03:23:06 +02:00
|
|
|
// Mastodon 4.3: several accounts at once (at most 40), those that exist, in no promised order
|
|
|
|
|
[HttpGet("/api/v1/accounts"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Many(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var accounts = new List<object>();
|
|
|
|
|
foreach (var id in Params.List("id").Distinct().Take(40))
|
|
|
|
|
if (await _mapper.Account(id, token) is { } account)
|
|
|
|
|
accounts.Add(account);
|
|
|
|
|
return Json(accounts);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var account = await _mapper.Account(id, token);
|
|
|
|
|
return account == default ? NotFoundError() : Json(account);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Statuses(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
2026-10-17 05:23:45 +02:00
|
|
|
// a suspended account's posts answer 404, as its account does (R6)
|
|
|
|
|
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
2026-10-01 12:05:58 +02:00
|
|
|
return NotFoundError();
|
|
|
|
|
if (Params.Bool("pinned") == true)
|
2026-10-01 12:13:57 +02:00
|
|
|
{
|
|
|
|
|
if (local == default)
|
2026-10-05 22:48:57 +02:00
|
|
|
{
|
|
|
|
|
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
|
|
|
|
|
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
|
|
|
|
|
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
|
|
|
|
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
|
|
|
|
}
|
2026-10-01 12:13:57 +02:00
|
|
|
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();
|
2026-10-03 11:51:19 +02:00
|
|
|
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
2026-10-01 12:13:57 +02:00
|
|
|
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
|
|
|
|
}
|
2026-10-01 12:05:58 +02:00
|
|
|
|
2026-10-03 11:56:39 +02:00
|
|
|
var query = local is { Kind: LocalActorKind.Group }
|
|
|
|
|
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
|
|
|
|
|
: local != default
|
2026-10-03 11:51:19 +02:00
|
|
|
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
|
|
|
|
|
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
|
2026-10-03 11:56:39 +02:00
|
|
|
var viewerFollows = MyId != default && (local != default
|
|
|
|
|
? await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
|
|
|
|
|
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
2026-10-01 12:05:58 +02:00
|
|
|
if (MyId != id)
|
|
|
|
|
query.Match(viewerFollows
|
|
|
|
|
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
|
|
|
|
|
: p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
|
|
|
|
|
else
|
|
|
|
|
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
|
|
|
|
|
if (Params.Bool("exclude_replies") == true)
|
2026-10-03 11:56:39 +02:00
|
|
|
{
|
|
|
|
|
var self = local?.Id ?? remote.ID;
|
|
|
|
|
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
|
|
|
|
|
}
|
2026-10-01 12:05:58 +02:00
|
|
|
if (Params.Bool("exclude_reblogs") == true)
|
|
|
|
|
query.Match(p => p.ReblogOfPostId == null);
|
|
|
|
|
if (Params.Bool("only_media") == true)
|
|
|
|
|
query.Match(p => p.Media.Count > 0);
|
|
|
|
|
if (Params.Get("tagged") is { } tag)
|
|
|
|
|
query.Match(p => p.Tags.Contains(tag.ToLowerInvariant()));
|
|
|
|
|
|
|
|
|
|
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
|
|
|
|
var statuses = await _mapper.Statuses(posts, MyId, token);
|
|
|
|
|
Link($"/api/v1/accounts/{id}/statuses", posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
|
|
|
|
return Json(statuses);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/followers"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Followers(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, _) = await Find(id, token);
|
|
|
|
|
if (local == default || local.Id != MyId)
|
|
|
|
|
return Json(Array.Empty<Account>());
|
2026-10-04 03:48:32 +02:00
|
|
|
var followers = await Page.From(Params, Limit(40, 80))
|
|
|
|
|
.Fetch(_dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted), f => f.ID, token);
|
|
|
|
|
Link($"/api/v1/accounts/{id}/followers", followers.LastOrDefault()?.ID, followers.FirstOrDefault()?.ID);
|
2026-10-01 12:05:58 +02:00
|
|
|
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> Following(string id, CancellationToken token)
|
|
|
|
|
{
|
2026-10-07 21:42:54 +02:00
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
// a feed's following is what it is: its communities, as its server lists them
|
|
|
|
|
if (remote is { AvatarType: AvatarType.Feed })
|
|
|
|
|
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
|
2026-10-01 12:05:58 +02:00
|
|
|
if (local == default || local.Id != MyId)
|
|
|
|
|
return Json(Array.Empty<Account>());
|
2026-10-04 03:48:32 +02:00
|
|
|
var following = await Page.From(Params, Limit(40, 80))
|
|
|
|
|
.Fetch(_dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted), f => f.ID, token);
|
|
|
|
|
Link($"/api/v1/accounts/{id}/following", following.LastOrDefault()?.ID, following.FirstOrDefault()?.ID);
|
2026-10-01 12:05:58 +02:00
|
|
|
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
|
|
|
|
|
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 02:15:28 +02:00
|
|
|
// PrivaPub's own: the feeds the persona reads (a feed's timeline is /api/v1/timelines/feed/:id)
|
|
|
|
|
[HttpGet("/api/v1/feeds"), Scope("read:follows")]
|
|
|
|
|
public async Task<IActionResult> Feeds(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var read = await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId).Sort(s => s.ID, MongoDB.Entities.Order.Descending).ExecuteAsync(token);
|
|
|
|
|
var accounts = await _mapper.Accounts(read.Select(s => s.FeedAccountId), token);
|
|
|
|
|
return Json(read.Select(s => accounts.GetValueOrDefault(s.FeedAccountId)).Where(a => a != default).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
|
|
|
|
|
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
2026-10-17 05:23:45 +02:00
|
|
|
// nobody follows a suspended account (R6), here or elsewhere: it is not shown
|
|
|
|
|
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
2026-10-01 12:05:58 +02:00
|
|
|
return NotFoundError();
|
2026-10-08 02:15:28 +02:00
|
|
|
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
2026-10-07 21:42:54 +02:00
|
|
|
if (remote is { AvatarType: AvatarType.Feed })
|
2026-10-08 02:15:28 +02:00
|
|
|
{
|
|
|
|
|
await _feeds.Subscribe(Me, remote, true, token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
2026-10-18 08:38:11 +02:00
|
|
|
// notify: a `status` notification for each of its new posts; languages: only its posts in those reach the home (R8).
|
|
|
|
|
// Either changes only when given, as on Mastodon
|
|
|
|
|
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token, Params.Bool("notify"),
|
|
|
|
|
Params.Has("languages") ? Params.List("languages") : default);
|
2026-10-01 12:05:58 +02:00
|
|
|
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/unfollow"), Scope("write:follows")]
|
|
|
|
|
public async Task<IActionResult> Unfollow(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default)
|
|
|
|
|
return NotFoundError();
|
2026-10-08 02:15:28 +02:00
|
|
|
if (remote is { AvatarType: AvatarType.Feed })
|
|
|
|
|
await _feeds.Subscribe(Me, remote, false, token);
|
|
|
|
|
else
|
|
|
|
|
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
2026-10-01 12:05:58 +02:00
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/remove_from_followers"), Scope("write:follows")]
|
|
|
|
|
public async Task<IActionResult> RemoveFromFollowers(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
var uri = local?.Uri ?? remote?.ActorURI;
|
|
|
|
|
if (uri == default)
|
|
|
|
|
return NotFoundError();
|
2026-10-03 11:56:39 +02:00
|
|
|
await _relationships.RemoveFollower(Me, uri, id, token);
|
2026-10-01 12:05:58 +02:00
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:13:57 +02:00
|
|
|
[HttpPost("/api/v1/accounts/{id}/block"), Scope("write:blocks")]
|
|
|
|
|
public async Task<IActionResult> Block(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default || local?.Id == MyId)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
await _relationships.Block(Me, local?.Uri ?? remote.ActorURI, id, token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/unblock"), Scope("write:blocks")]
|
|
|
|
|
public async Task<IActionResult> Unblock(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
await _relationships.Unblock(Me, local?.Uri ?? remote.ActorURI, token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/mute"), Scope("write:mutes")]
|
|
|
|
|
public async Task<IActionResult> Mute(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default || local?.Id == MyId)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
var duration = Params.Int("duration") is { } seconds and > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null;
|
|
|
|
|
await _relationships.Mute(Me, local?.Uri ?? remote.ActorURI, id, Params.Bool("notifications") != false, duration, token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/unmute"), Scope("write:mutes")]
|
|
|
|
|
public async Task<IActionResult> Unmute(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
await _relationships.Unmute(Me, local?.Uri ?? remote.ActorURI, token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
|
|
|
|
|
public async Task<IActionResult> Blocks(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Block>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
|
|
|
|
var accounts = await _mapper.Accounts(blocks.Select(b => b.TargetAccountId), token);
|
|
|
|
|
Link("/api/v1/blocks", blocks.LastOrDefault()?.ID, blocks.FirstOrDefault()?.ID);
|
|
|
|
|
return Json(blocks.Select(b => accounts.GetValueOrDefault(b.TargetAccountId)).Where(a => a != default).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
|
|
|
|
|
public async Task<IActionResult> Mutes(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId), m => m.ID, token);
|
|
|
|
|
var accounts = await _mapper.Accounts(mutes.Select(m => m.TargetAccountId), token);
|
|
|
|
|
Link("/api/v1/mutes", mutes.LastOrDefault()?.ID, mutes.FirstOrDefault()?.ID);
|
|
|
|
|
return Json(mutes.Select(m => accounts.GetValueOrDefault(m.TargetAccountId)).Where(a => a != default).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
|
|
|
|
|
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
|
|
|
|
|
Json((await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
|
|
|
|
|
|
2026-10-18 09:18:26 +02:00
|
|
|
// Mastodon's preview of a server block: how many of the accounts the persona follows, and of those following it, the
|
|
|
|
|
// block would take away, counted as BlockDomain takes them (an account on the server or a subdomain, or whose handle
|
|
|
|
|
// is)
|
|
|
|
|
[HttpGet("/api/v1/domain_blocks/preview"), Scope("write:blocks")]
|
|
|
|
|
public async Task<IActionResult> PreviewDomainBlock(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var domain = Params.Get("domain")?.Trim().ToLowerInvariant();
|
|
|
|
|
if (string.IsNullOrEmpty(domain))
|
|
|
|
|
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: domain");
|
|
|
|
|
if (Uri.CheckHostName(domain) != UriHostNameType.Dns)
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Domain is invalid");
|
|
|
|
|
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == MyId && !f.TargetIsLocal && f.State == FollowState.Accepted)
|
|
|
|
|
.ExecuteAsync(token);
|
|
|
|
|
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.IsAccepted).ExecuteAsync(token);
|
|
|
|
|
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
|
|
|
|
|
return Json(new
|
|
|
|
|
{
|
|
|
|
|
following_count = followings.Count(f => Hidden.Blocks(domain, f.TargetActorURI, handles.GetValueOrDefault(f.TargetActorURI))),
|
|
|
|
|
followers_count = followers.Count(f => Hidden.Blocks(domain, f.ActorURI, handles.GetValueOrDefault(f.ActorURI)))
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:13:57 +02:00
|
|
|
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
|
|
|
|
|
public async Task<IActionResult> BlockDomain(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
await _relationships.BlockDomain(Me, Params.Get("domain"), token);
|
|
|
|
|
return Json(new { });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpDelete("/api/v1/domain_blocks"), Scope("write:blocks")]
|
|
|
|
|
public async Task<IActionResult> UnblockDomain(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
await _relationships.UnblockDomain(Me, Params.Get("domain"), token);
|
|
|
|
|
return Json(new { });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
[HttpGet("/api/v1/follow_requests"), Scope("read:follows")]
|
|
|
|
|
public async Task<IActionResult> FollowRequests(CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var requests = await _dbEntities.Followers.Match(f => f.LocalActorId == Me.Id && f.LocalActorKind == LocalActorKind.Person && !f.IsAccepted)
|
|
|
|
|
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
|
|
|
|
return Json(await AccountsFor(requests.Select(r => r.ActorURI), token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/follow_requests/{id}/authorize"), Scope("write:follows")]
|
|
|
|
|
public async Task<IActionResult> Authorize(string id, CancellationToken token) =>
|
|
|
|
|
await _follows.Decide(Me, id, accept: true, token) ? Json(await Relationship(id, token)) : NotFoundError();
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/follow_requests/{id}/reject"), Scope("write:follows")]
|
|
|
|
|
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
|
|
|
|
|
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
|
|
|
|
|
|
2026-10-18 08:38:11 +02:00
|
|
|
// Familiar followers stay empty by design: which of the accounts the persona follows also follow another account would
|
|
|
|
|
// tell who follows whom among the personas here (and their followers lists are never shown, hide_collections), so
|
|
|
|
|
// every account answers none
|
2026-10-01 12:05:58 +02:00
|
|
|
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
|
|
|
|
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
|
|
|
|
|
2026-10-18 08:38:11 +02:00
|
|
|
// Endorsements (R8): Mastodon's account pins under their old names (pin, unpin) and 4.4's (endorse, unendorse). Only an
|
|
|
|
|
// account the persona follows may be endorsed, as Mastodon requires; they are the persona's alone, never federated as
|
|
|
|
|
// featured accounts and never listed to anyone else (the owner's default: endorsements stay private)
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/pin"), HttpPost("/api/v1/accounts/{id}/endorse"), Scope("write:accounts")]
|
|
|
|
|
public async Task<IActionResult> Endorse(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default || local?.Id == MyId || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
var uri = local?.Uri ?? remote.ActorURI;
|
|
|
|
|
if (!await Follows(uri, token))
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must be already following the person you want to endorse");
|
|
|
|
|
if (!await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token))
|
|
|
|
|
{
|
|
|
|
|
if (await DB.Default.CountAsync<Endorsement>(e => e.AvatarId == MyId, token) >= MaxEndorsements)
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can endorse at most {MaxEndorsements} accounts");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await DB.Default.SaveAsync(new Endorsement { AvatarId = MyId, TargetAccountId = id, TargetActorURI = uri }, token);
|
|
|
|
|
}
|
|
|
|
|
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/unpin"), HttpPost("/api/v1/accounts/{id}/unendorse"), Scope("write:accounts")]
|
|
|
|
|
public async Task<IActionResult> Unendorse(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
var uri = local?.Uri ?? remote.ActorURI;
|
|
|
|
|
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == MyId && (e.TargetActorURI == uri || e.TargetAccountId == id));
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// the persona's own endorsements, newest first, those it still follows
|
|
|
|
|
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
|
|
|
|
public Task<IActionResult> Endorsements(CancellationToken token) => EndorsementsOf("/api/v1/endorsements", token);
|
|
|
|
|
|
|
|
|
|
// Mastodon 4.4: an account's endorsements. Only the persona's own are answered, to the persona itself: endorsements
|
|
|
|
|
// stay private, so every other account answers none
|
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/endorsements"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
|
|
|
public async Task<IActionResult> EndorsementsOfAccount(string id, CancellationToken token) =>
|
|
|
|
|
MyId != default && id == MyId ? await EndorsementsOf($"/api/v1/accounts/{id}/endorsements", token) : Json(Array.Empty<Account>());
|
|
|
|
|
|
|
|
|
|
async Task<IActionResult> EndorsementsOf(string path, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && f.FollowerKind == null)
|
|
|
|
|
.Project(f => f.Include(x => x.TargetActorURI)).ExecuteAsync(token)).Select(f => f.TargetActorURI).ToList();
|
|
|
|
|
var page = await Page.From(Params, Limit(40, 80))
|
|
|
|
|
.Fetch(DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && followed.Contains(e.TargetActorURI)), e => e.ID, token);
|
|
|
|
|
Link(path, page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
|
|
|
|
var accounts = await _mapper.Accounts(page.Select(e => e.TargetAccountId), token);
|
|
|
|
|
return Json(page.Select(e => accounts.GetValueOrDefault(e.TargetAccountId)).Where(a => a != default).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A private note on an account (R8): the persona's own (PersonaNote, never its login's), shown only to it in the
|
|
|
|
|
// relationship's `note`, never federated; an empty comment takes it away
|
|
|
|
|
[HttpPost("/api/v1/accounts/{id}/note"), Scope("write:accounts")]
|
|
|
|
|
public async Task<IActionResult> Note(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
if (local == default && remote == default)
|
|
|
|
|
return NotFoundError();
|
|
|
|
|
var comment = Params.Get("comment")?.Trim();
|
|
|
|
|
if (comment?.Length > PersonaNote.MaxLength)
|
|
|
|
|
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Comment is too long (maximum is {PersonaNote.MaxLength} characters)");
|
|
|
|
|
if (string.IsNullOrEmpty(comment))
|
|
|
|
|
await DB.Default.DeleteAsync<PersonaNote>(n => n.AvatarId == MyId && n.TargetAccountId == id);
|
|
|
|
|
else
|
|
|
|
|
await DB.Default.Update<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id)
|
|
|
|
|
.Modify(n => n.Text, comment)
|
|
|
|
|
.Modify(n => n.UpdatedAt, DateTime.UtcNow)
|
|
|
|
|
.Modify(b => b.SetOnInsert(n => n.CreatedAt, DateTime.UtcNow))
|
|
|
|
|
.Option(o => o.IsUpsert = true)
|
|
|
|
|
.ExecuteAsync(token);
|
|
|
|
|
return Json(await Relationship(id, token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mastodon's preferences: posting defaults (update_credentials' source) and reading ones (R8: AvatarSettings, set
|
|
|
|
|
// through /clientapi/avatar/private/preferences, since Mastodon's API sets none)
|
|
|
|
|
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
|
|
|
|
|
public IActionResult Preferences() => Json(new Dictionary<string, object>
|
|
|
|
|
{
|
|
|
|
|
["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public",
|
|
|
|
|
["posting:default:sensitive"] = Me.Settings.DefaultSensitive,
|
|
|
|
|
["posting:default:language"] = Me.Settings.DefaultLanguage,
|
|
|
|
|
["reading:expand:media"] = ExpandMediaModes.IsKnown(Me.Settings.ExpandMedia) ? Me.Settings.ExpandMedia : ExpandMediaModes.Default,
|
|
|
|
|
["reading:expand:spoilers"] = Me.Settings.ExpandSpoilers
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const int MaxEndorsements = 500;
|
|
|
|
|
|
|
|
|
|
async Task<bool> Follows(string uri, CancellationToken token) =>
|
|
|
|
|
await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri && f.State == FollowState.Accepted && f.FollowerKind == null)
|
|
|
|
|
.ExecuteAnyAsync(token);
|
|
|
|
|
|
2026-10-01 12:05:58 +02:00
|
|
|
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var accounts = new List<Account>();
|
|
|
|
|
foreach (var uri in actorUris)
|
|
|
|
|
{
|
|
|
|
|
var local = await _localActors.FindByUri(uri, token);
|
|
|
|
|
if (local != default)
|
|
|
|
|
{
|
|
|
|
|
accounts.Add(await _mapper.Local(local, false, token));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == uri).ExecuteFirstAsync(token);
|
|
|
|
|
if (foreign != default)
|
|
|
|
|
accounts.Add(_mapper.Foreign(foreign));
|
|
|
|
|
}
|
|
|
|
|
return accounts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<(LocalActor Local, ForeignAvatar Remote)> Find(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
|
|
|
|
|
if (avatar is { DeletionAt: null })
|
|
|
|
|
return (_localActors.FromAvatar(avatar), default);
|
|
|
|
|
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
2026-10-01 12:30:57 +02:00
|
|
|
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
|
2026-10-01 12:05:58 +02:00
|
|
|
return (community, default);
|
|
|
|
|
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<Relationship> Relationship(string id, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var (local, remote) = await Find(id, token);
|
|
|
|
|
var uri = local?.Uri ?? remote?.ActorURI;
|
|
|
|
|
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
|
2026-10-08 02:15:28 +02:00
|
|
|
var reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, token);
|
2026-10-01 12:05:58 +02:00
|
|
|
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
2026-10-01 12:13:57 +02:00
|
|
|
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
|
|
|
|
|
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
|
2026-10-04 23:49:15 +02:00
|
|
|
var blockedBy = local != default
|
|
|
|
|
? await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token)
|
|
|
|
|
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
2026-10-06 11:49:32 +02:00
|
|
|
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
|
|
|
|
|
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
|
2026-10-18 08:38:11 +02:00
|
|
|
// the persona's own endorsement and note (R8), never another persona's: a note made as a sibling stays its own
|
|
|
|
|
var endorsed = uri != default && following?.State == FollowState.Accepted
|
|
|
|
|
&& await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token);
|
|
|
|
|
var note = await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id).ExecuteFirstAsync(token);
|
2026-10-01 12:05:58 +02:00
|
|
|
return new Relationship
|
|
|
|
|
{
|
|
|
|
|
Id = id,
|
2026-10-01 12:13:57 +02:00
|
|
|
Blocking = blocking,
|
|
|
|
|
BlockedBy = blockedBy,
|
|
|
|
|
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
|
|
|
|
MutingNotifications = mute?.HideNotifications == true,
|
|
|
|
|
DomainBlocking = domainBlocked,
|
2026-10-08 02:15:28 +02:00
|
|
|
Following = following?.State == FollowState.Accepted || reading,
|
2026-10-01 12:05:58 +02:00
|
|
|
Requested = following?.State == FollowState.Requested,
|
|
|
|
|
ShowingReblogs = following?.ShowReblogs ?? false,
|
2026-10-18 08:38:11 +02:00
|
|
|
Notifying = following?.Notify ?? false,
|
|
|
|
|
Languages = following?.Languages,
|
2026-10-01 12:05:58 +02:00
|
|
|
FollowedBy = followedBy?.IsAccepted == true,
|
2026-10-18 08:38:11 +02:00
|
|
|
RequestedBy = followedBy is { IsAccepted: false },
|
|
|
|
|
Endorsed = endorsed,
|
|
|
|
|
Note = note?.Text ?? string.Empty
|
2026-10-01 12:05:58 +02:00
|
|
|
};
|
|
|
|
|
}
|
2026-10-01 11:52:31 +02:00
|
|
|
}
|
|
|
|
|
}
|