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 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 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(); 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 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 Relationships(CancellationToken token) { var relationships = new List(); 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 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 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 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()); var pinIds = (await DB.Default.Find().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 Followers(string id, CancellationToken token) { var (local, _) = await Find(id, token); if (local == default || local.Id != MyId) return Json(Array.Empty()); 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 Following(string id, CancellationToken token) { var (local, _) = await Find(id, token); if (local == default || local.Id != MyId) return Json(Array.Empty()); 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 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 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 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(f => f.LocalActorId == Me.Id && f.ActorURI == uri); if (local != default) await DB.Default.DeleteAsync(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 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 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 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 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 Blocks(CancellationToken token) { var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().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 Mutes(CancellationToken token) { var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().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 DomainBlocks(CancellationToken token) => Json((await DB.Default.Find().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 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 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 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 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 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()); [HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")] public IActionResult Lists(string id) => Json(Array.Empty()); [HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")] public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty() }).ToList()); async Task> AccountsFor(IEnumerable actorUris, CancellationToken token) { var accounts = new List(); 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(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().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token); var mute = uri == default ? default : await DB.Default.Find().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token); var blockedBy = local != default && await DB.Default.Find().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token); var domainBlocked = remote != default && await DB.Default.Find().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 } }; } } }