LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.
Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
494 lines
25 KiB
C#
494 lines
25 KiB
C#
using Microsoft.AspNetCore.RateLimiting;
|
|
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 PrivaPub.Infrastructure;
|
|
|
|
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;
|
|
readonly IFeedFollows _feeds;
|
|
|
|
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
|
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
|
|
{
|
|
_feeds = feeds;
|
|
_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"), EnableRateLimiting(RateLimiting.Uploads), 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);
|
|
// 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>();
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync(token);
|
|
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);
|
|
}
|
|
}
|
|
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;
|
|
// hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
|
|
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.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(avatar);//the server's custom emoji its name, note and fields use (R7)
|
|
avatar.UpdatedAt = DateTime.UtcNow;
|
|
await DB.Default.SaveAsync(avatar, token);
|
|
foreach (var made in replaced)
|
|
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == made.Kind && m.ID != made.ID, "replaced", token);
|
|
|
|
var actor = _localActors.FromAvatar(avatar);
|
|
await _outbox.PublishProfile(actor, token);
|
|
return Json(await _mapper.Local(actor, withSource: true, token));
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
[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, IsServerActor: false, Suspended: false } ? 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 && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
|
.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(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));
|
|
|
|
// 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);
|
|
}
|
|
|
|
[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);
|
|
// 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 })
|
|
return NotFoundError();
|
|
if (Params.Bool("pinned") == true)
|
|
{
|
|
if (local == default)
|
|
{
|
|
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));
|
|
}
|
|
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 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);
|
|
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, 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));
|
|
if (local == default || local.Id != MyId)
|
|
return Json(Array.Empty<Account>());
|
|
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);
|
|
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());
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
|
|
[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);
|
|
// 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 })
|
|
return NotFoundError();
|
|
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
|
if (remote is { AvatarType: AvatarType.Feed })
|
|
{
|
|
await _feeds.Subscribe(Me, remote, true, token);
|
|
return Json(await Relationship(id, token));
|
|
}
|
|
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();
|
|
if (remote is { AvatarType: AvatarType.Feed })
|
|
await _feeds.Subscribe(Me, remote, false, token);
|
|
else
|
|
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/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 reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, 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)
|
|
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
|
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));
|
|
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 || reading,
|
|
Requested = following?.State == FollowState.Requested,
|
|
ShowingReblogs = following?.ShowReblogs ?? false,
|
|
FollowedBy = followedBy?.IsAccepted == true,
|
|
RequestedBy = followedBy is { IsAccepted: false }
|
|
};
|
|
}
|
|
}
|
|
}
|