Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
post whose ActorURI is the actor (one update-many), besides dropping its follows and
timeline rows as before. RemotePosts.Build sets it on a post stored later for an
account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
bookmarks, favourites, polls, reactions, search); provenance, account statuses,
home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
home and post/DM lists, a community's outbox and our Announces filter on IsShown or
IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.
Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
Referrer-Policy and nosniff), circle 404, community page, the instance actor,
ActivityPub redirects, and markup escaped in posts, titles and bios.
Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
402 lines
20 KiB
C#
402 lines
20 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
|
using PrivaPub.Api.Mastodon.Mappers;
|
|
using PrivaPub.Domain.Media;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Domain.Relationships;
|
|
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;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Controllers
|
|
{
|
|
public class AccountsController : MastodonController
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly IFollowService _follows;
|
|
readonly IOutboxPublisher _outbox;
|
|
readonly IRelationshipService _relationships;
|
|
|
|
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
|
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships)
|
|
{
|
|
_relationships = relationships;
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
_follows = follows;
|
|
_outbox = outbox;
|
|
}
|
|
|
|
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
|
|
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
|
|
|
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), RequestSizeLimit(20 * 1024 * 1024),
|
|
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
|
|
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
|
{
|
|
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync(token);
|
|
if (form.Files["avatar"] is { } picture && await media.ProfileImage(picture, 400, 400, token) is { } pictureUrl)
|
|
avatar.PictureURL = pictureUrl;
|
|
if (form.Files["header"] is { } header && await media.ProfileImage(header, 1500, 500, token) is { } headerUrl)
|
|
avatar.ThumbnailURL = headerUrl;
|
|
}
|
|
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;
|
|
if (Params.Bool("hide_collections") is { } hide)
|
|
avatar.Settings.HideCollections = hide;
|
|
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]");
|
|
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
|
avatar.Settings.QuotePolicy = quotePolicy;
|
|
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;
|
|
avatar.UpdatedAt = DateTime.UtcNow;
|
|
await DB.Default.SaveAsync(avatar, token);
|
|
|
|
var actor = _localActors.FromAvatar(avatar);
|
|
await _outbox.PublishProfile(actor, token);
|
|
return Json(await _mapper.Local(actor, withSource: true, token));
|
|
}
|
|
|
|
[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);
|
|
return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
|
}
|
|
var userName = parts[0];
|
|
var domain = parts[1].ToLowerInvariant();
|
|
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
|
|
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) =>
|
|
Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
|
|
|
[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);
|
|
if (local == default && remote == default)
|
|
return NotFoundError();
|
|
if (Params.Bool("pinned") == true)
|
|
{
|
|
if (local == default)
|
|
return Json(Array.Empty<Status>());
|
|
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();
|
|
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
|
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
|
}
|
|
|
|
var query = local != default
|
|
? _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);
|
|
var viewerFollows = MyId != default && local != default
|
|
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
|
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)
|
|
query.Match(p => p.InReplyToURI == null);
|
|
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>());
|
|
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted)
|
|
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
|
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)
|
|
{
|
|
var (local, _) = await Find(id, token);
|
|
if (local == default || local.Id != MyId)
|
|
return Json(Array.Empty<Account>());
|
|
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted)
|
|
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
|
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());
|
|
}
|
|
|
|
[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);
|
|
if (local == default && remote == default)
|
|
return NotFoundError();
|
|
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
|
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();
|
|
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
|
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();
|
|
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri);
|
|
if (local != default)
|
|
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
|
|
return Json(await Relationship(id, token));
|
|
}
|
|
|
|
[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());
|
|
|
|
[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 { });
|
|
}
|
|
|
|
[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();
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
|
public IActionResult Lists(string id) => Json(Array.Empty<object>());
|
|
|
|
[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());
|
|
|
|
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);
|
|
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
|
|
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);
|
|
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
|
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);
|
|
var blockedBy = local != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token);
|
|
var domainBlocked = remote != default && await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token);
|
|
return new Relationship
|
|
{
|
|
Id = id,
|
|
Blocking = blocking,
|
|
BlockedBy = blockedBy,
|
|
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
|
MutingNotifications = mute?.HideNotifications == true,
|
|
DomainBlocking = domainBlocked,
|
|
Following = following?.State == FollowState.Accepted,
|
|
Requested = following?.State == FollowState.Requested,
|
|
ShowingReblogs = following?.ShowReblogs ?? false,
|
|
FollowedBy = followedBy?.IsAccepted == true,
|
|
RequestedBy = followedBy is { IsAccepted: false }
|
|
};
|
|
}
|
|
}
|
|
}
|