88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.
Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
with indexed and array fields_attributes (form and JSON), source[*],
quote_policy, locked/bot, avatar and header uploads resized and stripped of
EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
and a circle's id answer 404); search with and without resolve (only a
signed-in persona resolves, the Peer is untouched otherwise), by post and
actor address, hashtags, undiscoverable personas; account statuses with
pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
both ways; followers-only posts for followers (local and remote authors);
community accounts; followers/following only to their owner; follow (open,
locked, remote Follow delivery), unfollow and Undo; follow requests from
local and remote followers answered with the original Follow;
remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
with duration and the notifications choice, never federated; domain blocks;
relationships with junk ids; a banned login's tokens; reports forwarded as a
Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
local and remote posts (mention, inReplyTo, the author's inbox); polls and
votes (local, and remote votes only to the author without published); media
attached only by its owner; quotes, the quotes list and revocation; edit
history, source and the Update delivery; delete for redraft, the 410
Tombstone and the Delete delivery; favourite/reblog counts with Like,
Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
interaction_policy matching canQuote in the note and in the Update;
strangers get 404 for followers-only and direct posts; a located post is
unreachable by id for anyone else on every route; statuses?id[];
Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
remote; tag (anonymous); list stub; favourites; conversations and read;
markers; notifications with types[], exclude_types[], account_id, paging,
get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
unreadable files, video and audio made with ffmpeg lavfi sources and checked
with ffprobe; every remote media address goes through the proxy; the proxy
refuses unsigned URLs, streams ranges as 206 without caching, caches whole
downloads and serves them with ranges, and streams anything over
Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
no signature, the trigger as activity) posts, visibility of provenance,
instance descriptions; reading any of them makes no outbound request.
Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
notifications.
Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
follower. It now sends Reject{Follow} with the stored Follow id, through
RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
so a post in their home timeline answered 404 to GET, context, favourite and
reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
post was gone; it answers 404.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
406 lines
20 KiB
C#
406 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 is { Kind: LocalActorKind.Group }
|
|
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
|
|
: 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)
|
|
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && 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)
|
|
{
|
|
var self = local?.Id ?? remote.ID;
|
|
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
|
|
}
|
|
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 _relationships.RemoveFollower(Me, uri, id, token);
|
|
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 }
|
|
};
|
|
}
|
|
}
|
|
}
|