diff --git a/PrivaPub.Tests/Api/MastodonParamsTests.cs b/PrivaPub.Tests/Api/MastodonParamsTests.cs new file mode 100644 index 0000000..015722a --- /dev/null +++ b/PrivaPub.Tests/Api/MastodonParamsTests.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Http; + +using PrivaPub.Api.Mastodon.Auth; +using PrivaPub.Api.Mastodon.Infrastructure; + +using System.Text; + +namespace PrivaPub.Tests.Api +{ + public class MastodonParamsTests + { + static async Task Read(string query = "", string contentType = default, string body = default) + { + var context = new DefaultHttpContext(); + context.Request.QueryString = new QueryString(query); + if (body != default) + { + context.Request.ContentType = contentType; + var bytes = Encoding.UTF8.GetBytes(body); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength = bytes.Length; + } + return await MastodonParams.Read(context.Request, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Absent_values_are_null_not_false_or_zero() + { + var parameters = await Read(); + + Assert.Null(parameters.Bool("sensitive")); + Assert.Null(parameters.Int("limit")); + Assert.Null(parameters.Get("status")); + Assert.Empty(parameters.List("media_ids")); + } + + [Fact] + public async Task Reads_rails_style_arrays_from_the_query() + { + var parameters = await Read("?id[]=1&id[]=2&limit=5&local=true"); + + Assert.Equal(new[] { "1", "2" }, parameters.List("id")); + Assert.Equal(5, parameters.Int("limit")); + Assert.True(parameters.Bool("local")); + } + + [Fact] + public async Task Reads_forms_and_nested_keys() + { + var parameters = await Read(contentType: "application/x-www-form-urlencoded", + body: "status=hi&media_ids[]=a&media_ids[]=b&poll[options][]=x&poll[options][]=y&sensitive=0"); + + Assert.Equal("hi", parameters.Get("status")); + Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids")); + Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]")); + Assert.False(parameters.Bool("sensitive")); + } + + [Fact] + public async Task Reads_json_bodies_the_same_way() + { + var parameters = await Read(contentType: "application/json", + body: """{"status":"hi","media_ids":["a","b"],"poll":{"options":["x","y"],"expires_in":300},"sensitive":true,"source":{"privacy":"unlisted"}}"""); + + Assert.Equal(new[] { "a", "b" }, parameters.List("media_ids")); + Assert.Equal(new[] { "x", "y" }, parameters.List("poll[options]")); + Assert.Equal(300, parameters.Int("poll[expires_in]")); + Assert.True(parameters.Bool("sensitive")); + Assert.Equal("unlisted", parameters.Get("source[privacy]")); + } + + [Theory] + [InlineData(new[] { "read" }, "read:statuses", true)] + [InlineData(new[] { "read:statuses" }, "read:statuses", true)] + [InlineData(new[] { "read:accounts" }, "read:statuses", false)] + [InlineData(new[] { "write" }, "read:statuses", false)] + [InlineData(new[] { "follow" }, "write:follows", true)] + [InlineData(new[] { "follow" }, "write:statuses", false)] + public void Scopes_grant_like_mastodons(string[] granted, string required, bool expected) => + Assert.Equal(expected, MastodonScopes.Grants(granted, required)); + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 7f51fb9..f0c53fc 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -1,20 +1,294 @@ 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.Privacy; +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 partial class AccountsController : MastodonController + public class AccountsController : MastodonController { readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + readonly IFollowService _follows; + readonly IOutboxPublisher _outbox; - public AccountsController(MastodonMapper mapper) + public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, + IFollowService follows, IOutboxPublisher outbox) { _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")] + public async Task UpdateCredentials(CancellationToken token) + { + var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token); + 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]"); + 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, 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) + return Json(Array.Empty()); + + var query = local != default + ? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue) + : _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy && !p.DeletedAt.HasValue); + 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)); + } + + [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 } 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); + return new Relationship + { + Id = id, + Following = following?.State == FollowState.Accepted, + Requested = following?.State == FollowState.Requested, + ShowingReblogs = following?.ShowReblogs ?? false, + FollowedBy = followedBy?.IsAccepted == true, + RequestedBy = followedBy is { IsAccepted: false } + }; + } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs new file mode 100644 index 0000000..0485e13 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Mvc; + +using PrivaPub.Api.Mastodon.Entities; +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Privacy; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Outbox; +using PrivaPub.Domain.Social; +using PrivaPub.Models.Post; +using PrivaPub.StaticServices; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class SearchController : MastodonController + { + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + readonly IRemotePosts _remotePosts; + readonly AccountSearch _search; + + public SearchController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, + IRemotePosts remotePosts, AccountSearch search) + { + _mapper = mapper; + _dbEntities = dbEntities; + _localActors = localActors; + _remoteActors = remoteActors; + _remotePosts = remotePosts; + _search = search; + } + + [HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)] + public async Task Search(CancellationToken token) + { + var q = Params.Get("q")?.Trim(); + var type = Params.Get("type"); + var resolve = Params.Bool("resolve") == true && MyId != default; + var results = new SearchResults(); + if (string.IsNullOrEmpty(q)) + return Json(results); + + if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + var post = await _dbEntities.Posts.Match(p => (p.ObjectURI == q || p.Url == q) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + if (post == default && resolve && type is null or "statuses") + post = await _remotePosts.StoreContext(q, 0, token); + if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token)) + results.Statuses.Add(await _mapper.Status(post, MyId, token)); + if (results.Statuses.Count == 0 && type is null or "accounts") + { + var local = await _localActors.FindByUri(q, token); + var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default; + if (local is { IsFederated: true }) + results.Accounts.Add(await _mapper.Local(local, false, token)); + else if (foreign != default) + results.Accounts.Add(_mapper.Foreign(foreign)); + } + return Json(results); + } + + if (type is null or "accounts") + { + results.Accounts = await _search.Find(q, resolve, Limit(), token); + } + if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag) + results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty() }); + return Json(results); + } + } + + public class StubsController : MastodonController + { + [HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult CustomEmojis() => Json(Array.Empty()); + + [HttpGet("/api/v1/filters"), Scope("read:filters")] + public IActionResult FiltersV1() => Json(Array.Empty()); + + [HttpGet("/api/v2/filters"), Scope("read:filters")] + public IActionResult FiltersV2() => Json(Array.Empty()); + + [HttpGet("/api/v1/lists"), Scope("read:lists")] + public IActionResult Lists() => Json(Array.Empty()); + + [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult Announcements() => Json(Array.Empty()); + + [HttpGet("/api/v1/trends"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult Trends() => Json(Array.Empty()); + + [HttpGet("/api/v1/trends/tags"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult TrendTags() => Json(Array.Empty()); + + [HttpGet("/api/v1/trends/statuses"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult TrendStatuses() => Json(Array.Empty()); + + [HttpGet("/api/v1/trends/links"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult TrendLinks() => Json(Array.Empty()); + + [HttpGet("/api/v1/suggestions"), Scope("read")] + public IActionResult SuggestionsV1() => Json(Array.Empty()); + + [HttpGet("/api/v2/suggestions"), Scope("read")] + public IActionResult SuggestionsV2() => Json(Array.Empty()); + + [HttpGet("/api/v1/followed_tags"), Scope("read:follows")] + public IActionResult FollowedTags() => Json(Array.Empty()); + + [HttpGet("/api/v1/blocks"), Scope("read:blocks")] + public IActionResult Blocks() => Json(Array.Empty()); + + [HttpGet("/api/v1/mutes"), Scope("read:mutes")] + public IActionResult Mutes() => Json(Array.Empty()); + + [HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")] + public IActionResult DomainBlocks() => Json(Array.Empty()); + + [HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")] + public IActionResult Bookmarks() => Json(Array.Empty()); + + [HttpGet("/api/v1/endorsements"), Scope("read:accounts")] + public IActionResult Endorsements() => Json(Array.Empty()); + + [HttpGet("/api/v1/featured_tags"), Scope("read:accounts")] + public IActionResult FeaturedTags() => Json(Array.Empty()); + + [HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")] + public IActionResult ScheduledStatuses() => Json(Array.Empty()); + + [HttpGet("/api/v1/preferences"), Scope("read:accounts")] + public IActionResult Preferences() => Json(new Dictionary + { + ["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public", + ["posting:default:sensitive"] = Me.Settings.DefaultSensitive, + ["posting:default:language"] = Me.Settings.DefaultLanguage, + ["reading:expand:media"] = "default", + ["reading:expand:spoilers"] = false + }); + + [HttpGet("/api/v1/push/subscription"), Scope("push")] + public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found"); + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs new file mode 100644 index 0000000..e7aa599 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -0,0 +1,276 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Entities; +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Privacy; +using PrivaPub.Domain.Statuses; +using PrivaPub.Models.Post; +using PrivaPub.StaticServices; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class StatusesController : MastodonController + { + const int MaxContext = 200; + + readonly IStatusService _statuses; + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + readonly IMemoryCache _cache; + + public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache) + { + _statuses = statuses; + _mapper = mapper; + _dbEntities = dbEntities; + _cache = cache; + } + + [HttpPost("/api/v1/statuses"), Scope("write:statuses")] + public async Task Create(CancellationToken token) + { + var idempotency = Request.Headers["Idempotency-Key"].ToString(); + var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}"; + if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier)) + { + var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token); + if (existing != default) + return Json(await _mapper.Status(existing, MyId, token)); + } + if (Params.List("media_ids").Count > 0) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet"); + if (Params.Has("poll[options]")) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet"); + + var outcome = await _statuses.Publish(Me, new StatusDraft + { + Text = Params.Get("status"), + PlainText = true, + SpoilerText = Params.Get("spoiler_text"), + Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive, + Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility), + InReplyTo = Params.Get("in_reply_to_id"), + Language = Params.Get("language") ?? Me.Settings.DefaultLanguage + }, token); + if (!outcome.Ok) + return Error(outcome.Status, outcome.Error); + if (cacheKey != default) + _cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1)); + return Json(await _mapper.Status(outcome.Post, MyId, token)); + } + + [HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Many(CancellationToken token) + { + var ids = Params.List("id").Take(20).ToList(); + var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); + var visible = new List(); + foreach (var post in posts) + if (await VisibilityPolicy.CanSee(post, MyId, token)) + visible.Add(post); + return Json(await _mapper.Statuses(visible, MyId, token)); + } + + [HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Get(string id, CancellationToken token) + { + var post = await Visible(id, token); + return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token)); + } + + [HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")] + public async Task Edit(string id, CancellationToken token) + { + if (Params.List("media_ids").Count > 0) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet"); + var outcome = await _statuses.Edit(Me, id, new StatusDraft + { + Text = Params.Get("status"), + PlainText = true, + SpoilerText = Params.Get("spoiler_text"), + Sensitive = Params.Bool("sensitive") ?? false, + Language = Params.Get("language") + }, token); + return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); + } + + [HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")] + public async Task Delete(string id, CancellationToken token) + { + var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + if (before == default) + return NotFoundError(); + var status = await _mapper.Status(before, MyId, token); + var outcome = await _statuses.Remove(Me, id, token); + if (!outcome.Ok) + return Error(outcome.Status, outcome.Error); + if (status != default) + { + status.Text = before.Text; + status.SpoilerText = before.SpoilerText ?? string.Empty; + } + return Json(status); + } + + [HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Context(string id, CancellationToken token) + { + var post = await Visible(id, token); + if (post == default) + return NotFoundError(); + + var ancestors = new List(); + var cursor = post; + while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext) + { + cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token); + if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token)) + break; + ancestors.Insert(0, cursor); + } + + var descendants = new List(); + var frontier = new List { post.ID }; + while (frontier.Count > 0 && descendants.Count < MaxContext) + { + var parents = frontier; + var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) + .Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token); + frontier = new List(); + foreach (var child in children) + if (await VisibilityPolicy.CanSee(child, MyId, token)) + { + descendants.Add(child); + frontier.Add(child.ID); + } + } + return Json(new Context + { + Ancestors = await _mapper.Statuses(ancestors, MyId, token), + Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token) + }); + } + + [HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task History(string id, CancellationToken token) + { + var post = await Visible(id, token); + if (post == default) + return NotFoundError(); + var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token); + var edits = post.Revisions.Select(r => new StatusEdit + { + Content = r.ContentHtml ?? string.Empty, + SpoilerText = r.SpoilerText ?? string.Empty, + Sensitive = r.HasContentWarning, + CreatedAt = MastodonJson.Time(r.EditedAt), + Account = account + }).ToList(); + edits.Add(new StatusEdit + { + Content = MastodonMapper.Content(post), + SpoilerText = post.SpoilerText ?? string.Empty, + Sensitive = post.HasContentWarning, + CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate), + Account = account + }); + return Json(edits); + } + + [HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")] + public async Task Source(string id, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty }); + } + + [HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")] + public Task Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token); + + [HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")] + public Task Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token); + + [HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")] + public async Task Reblog(string id, CancellationToken token) + { + var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token); + return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); + } + + [HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")] + public Task Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token); + + [HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task FavouritedBy(string id, CancellationToken token) + { + if (await Visible(id, token) == default) + return NotFoundError(); + var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); + var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token); + return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList()); + } + + [HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task RebloggedBy(string id, CancellationToken token) + { + if (await Visible(id, token) == default) + return NotFoundError(); + var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); + var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token); + return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); + } + + [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] + public Task Bookmark(string id, CancellationToken token) => Unchanged(id, token); + + [HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")] + public Task Unbookmark(string id, CancellationToken token) => Unchanged(id, token); + + [HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")] + public Task Mute(string id, CancellationToken token) => Unchanged(id, token); + + [HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")] + public Task Unmute(string id, CancellationToken token) => Unchanged(id, token); + + [HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")] + public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet"); + + [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")] + public Task Unpin(string id, CancellationToken token) => Unchanged(id, token); + + async Task Toggle(Task action, string id, CancellationToken token) + { + var outcome = await action; + if (!outcome.Ok) + return Error(outcome.Status, outcome.Error); + var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token); + return Json(await _mapper.Status(post, MyId, token)); + } + + async Task Unchanged(string id, CancellationToken token) + { + var post = await Visible(id, token); + return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token)); + } + + async Task Visible(string id, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default; + } + + static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch + { + "unlisted" => PostVisibility.Unlisted, + "private" => PostVisibility.FollowersOnly, + "direct" => PostVisibility.Direct, + _ => PostVisibility.Public + }; + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs new file mode 100644 index 0000000..0e5dfdc --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -0,0 +1,243 @@ +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.Privacy; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +using PostEntity = PrivaPub.Models.Post.Post; +using NotificationEntity = PrivaPub.Models.Social.Notification; +using MarkerEntity = PrivaPub.Models.Social.Marker; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class TimelinesController : MastodonController + { + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + + public TimelinesController(MastodonMapper mapper, DbEntities dbEntities) + { + _mapper = mapper; + _dbEntities = dbEntities; + } + + [HttpGet("/api/v1/timelines/home"), Scope("read:statuses")] + public async Task Home(CancellationToken token) + { + var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token); + var ids = entries.Select(e => e.PostId).ToList(); + var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); + var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token); + Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId); + return Json(statuses); + } + + [HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Public(CancellationToken token) + { + var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); + if (Params.Bool("local") == true) + query.Match(p => !p.IsFederatedCopy); + if (Params.Bool("remote") == true) + query.Match(p => p.IsFederatedCopy); + if (Params.Bool("only_media") == true) + query.Match(p => p.Media.Count > 0); + return await Respond(query, "/api/v1/timelines/public", token); + } + + [HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Tag(string hashtag, CancellationToken token) + { + var tag = hashtag.TrimStart('#').ToLowerInvariant(); + var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); + if (Params.Bool("local") == true) + query.Match(p => !p.IsFederatedCopy); + return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); + } + + [HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")] + public IActionResult List(string id) => Json(Array.Empty()); + + [HttpGet("/api/v1/favourites"), Scope("read:favourites")] + public async Task Favourites(CancellationToken token) + { + var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token); + var ids = favourites.Select(f => f.PostId).ToList(); + var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); + var visible = new List(); + foreach (var id in ids.Where(posts.ContainsKey)) + if (await VisibilityPolicy.CanSee(posts[id], MyId, token)) + visible.Add(posts[id]); + Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID); + return Json(await _mapper.Statuses(visible, MyId, token)); + } + + [HttpGet("/api/v1/conversations"), Scope("read:statuses")] + public async Task Conversations(CancellationToken token) + { + var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)) + .Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token); + var conversations = new List(); + foreach (var group in groups) + { + var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); + if (last == default) + continue; + var others = new List(); + foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))) + others.Add(member.IsForeign + ? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID + : member.AvatarId); + var accounts = await _mapper.Accounts(others, token); + conversations.Add(new Conversation + { + Id = group.ID, + Accounts = accounts.Values.ToList(), + LastStatus = await _mapper.Status(last, MyId, token) + }); + } + return Json(conversations); + } + + [HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")] + public async Task ReadConversation(string id, CancellationToken token) + { + var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token); + if (group == default) + return NotFoundError(); + var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); + return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) }); + } + + [HttpGet("/api/v1/markers"), Scope("read:statuses")] + public async Task Markers(CancellationToken token) + { + var wanted = Params.List("timeline"); + var markers = await DB.Default.Find().Match(m => m.AvatarId == MyId).ExecuteAsync(token); + return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline)) + .ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) })); + } + + [HttpPost("/api/v1/markers"), Scope("write:statuses")] + public async Task SaveMarkers(CancellationToken token) + { + var result = new Dictionary(); + foreach (var timeline in new[] { "home", "notifications" }) + { + var lastRead = Params.Get($"{timeline}[last_read_id]"); + if (string.IsNullOrEmpty(lastRead)) + continue; + var marker = await DB.Default.UpdateAndGet() + .Match(m => m.AvatarId == MyId && m.Timeline == timeline) + .Modify(m => m.LastReadId, lastRead) + .Modify(m => m.UpdatedAt, DateTime.UtcNow) + .Modify(b => b.Inc(m => m.Version, 1)) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); + result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) }; + } + return Json(result); + } + + async Task Respond(Find query, string path, CancellationToken token) + { + var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token); + Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID); + return Json(await _mapper.Statuses(posts, MyId, token)); + } + } + + public class NotificationsController : MastodonController + { + static readonly Dictionary Names = new() + { + [NotificationType.Mention] = "mention", + [NotificationType.Follow] = "follow", + [NotificationType.FollowRequest] = "follow_request", + [NotificationType.Favourite] = "favourite", + [NotificationType.Reblog] = "reblog", + [NotificationType.Update] = "update" + }; + + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + + public NotificationsController(MastodonMapper mapper, DbEntities dbEntities) + { + _mapper = mapper; + _dbEntities = dbEntities; + } + + [HttpGet("/api/v1/notifications"), Scope("read:notifications")] + public async Task List(CancellationToken token) + { + var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList(); + var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList(); + var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId); + if (types.Count > 0) + query.Match(n => types.Contains(n.Type)); + if (excluded.Count > 0) + query.Match(n => !excluded.Contains(n.Type)); + if (Params.Get("account_id") is { } accountId) + query.Match(n => n.FromAccountId == accountId); + var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token); + Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID); + return Json(await Map(notifications, token)); + } + + [HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")] + public async Task Get(string id, CancellationToken token) + { + var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token); + return notification == default ? NotFoundError() : Json((await Map(new List { notification }, token)).FirstOrDefault()); + } + + [HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")] + public async Task Clear(CancellationToken token) + { + await DB.Default.DeleteAsync(n => n.AvatarId == MyId); + return Json(new { }); + } + + [HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")] + public async Task Dismiss(string id, CancellationToken token) + { + await DB.Default.DeleteAsync(n => n.ID == id && n.AvatarId == MyId); + return Json(new { }); + } + + [HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")] + public async Task UnreadCount(CancellationToken token) => + Json(new { count = await DB.Default.CountAsync(n => n.AvatarId == MyId && !n.IsRead, token) }); + + async Task> Map(List notifications, CancellationToken token) + { + var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token); + var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList(); + var posts = postIds.Count == 0 + ? new List() + : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); + var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id); + return notifications + .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) + .Select(n => new Entities.Notification + { + Id = n.ID, + Type = Names[n.Type], + CreatedAt = MastodonJson.Time(n.CreatedAt), + GroupKey = $"ungrouped-{n.ID}", + Account = accounts[n.FromAccountId], + Status = n.PostId == default ? default : statuses[n.PostId] + }) + .ToList(); + } + + static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null; + } +} diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs index 7a43915..67f6019 100644 --- a/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonParams.cs @@ -59,10 +59,10 @@ namespace PrivaPub.Api.Mastodon.Infrastructure { "true" or "1" or "on" or "yes" => true, "false" or "0" or "off" or "no" or "" => false, - _ => default + _ => (bool?)null }; - public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : default; + public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : (int?)null; static void Add(Dictionary> values, string key, string value) { diff --git a/PrivaPub/Api/Mastodon/Infrastructure/Paging.cs b/PrivaPub/Api/Mastodon/Infrastructure/Paging.cs new file mode 100644 index 0000000..c546cb6 --- /dev/null +++ b/PrivaPub/Api/Mastodon/Infrastructure/Paging.cs @@ -0,0 +1,30 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using System.Linq.Expressions; + +namespace PrivaPub.Api.Mastodon.Infrastructure +{ + public sealed record Page(string MaxId, string SinceId, string MinId, int Limit) + { + public static Page From(MastodonParams parameters, int limit) => + new(parameters.Get("max_id"), parameters.Get("since_id"), parameters.Get("min_id"), limit); + + public async Task> Fetch(Find query, Expression> id, CancellationToken token) where T : IEntity + { + var field = new ExpressionFieldDefinition(id); + if (!string.IsNullOrEmpty(MaxId)) + query.Match(Builders.Filter.Lt(field, MaxId)); + if (!string.IsNullOrEmpty(SinceId)) + query.Match(Builders.Filter.Gt(field, SinceId)); + if (!string.IsNullOrEmpty(MinId)) + query.Match(Builders.Filter.Gt(field, MinId)); + var ascending = !string.IsNullOrEmpty(MinId); + var sortField = new ExpressionFieldDefinition(Expression.Lambda>(Expression.Convert(id.Body, typeof(object)), id.Parameters)); + var items = await query.Sort(s => ascending ? s.Ascending(sortField) : s.Descending(sortField)).Limit(Limit).ExecuteAsync(token); + if (ascending) + items.Reverse(); + return items; + } + } +} diff --git a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs new file mode 100644 index 0000000..fd9bfbe --- /dev/null +++ b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs @@ -0,0 +1,55 @@ +using PrivaPub.Api.Mastodon.Entities; +using PrivaPub.Federation.Actors; +using PrivaPub.StaticServices; + +namespace PrivaPub.Api.Mastodon.Mappers +{ + public class AccountSearch + { + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IRemoteActorService _remoteActors; + + public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors) + { + _mapper = mapper; + _dbEntities = dbEntities; + _localActors = localActors; + _remoteActors = remoteActors; + } + + public async Task> Find(string q, bool resolve, int limit, CancellationToken token) + { + q = q?.Trim().TrimStart('@'); + if (string.IsNullOrEmpty(q)) + return new List(); + var results = new List(); + var parts = q.Split('@'); + var localDomain = new Uri(_localActors.BaseAddress).Authority; + if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0])) + { + var userName = parts[0]; + var domain = parts[1].ToLowerInvariant(); + var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token); + if (known == default && resolve) + { + var actorUri = await _remoteActors.ResolveHandle(q, token); + known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token); + } + if (known != default) + results.Add(_mapper.Foreign(known)); + return results; + } + + var prefix = parts[0].ToLowerInvariant(); + foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false) + .Limit(limit).ExecuteAsync(token)) + results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token)); + foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable) + .Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token)) + results.Add(_mapper.Foreign(foreign)); + return results; + } + } +} diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 8fad356..6d2fd25 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -55,7 +55,11 @@ namespace PrivaPub.Api.Mastodon.Mappers Acct = actor.UserName, DisplayName = actor.Name ?? actor.UserName, Locked = actor.ManuallyApprovesFollowers, + Bot = actor.IsBot, Discoverable = actor.Discoverable, + Indexable = actor.Indexable, + Noindex = !actor.Indexable, + HideCollections = actor.Settings.HideCollections, Group = actor.Kind == LocalActorKind.Group, CreatedAt = MastodonJson.Day(actor.Published), Note = ActivityPubRenderer.Html(actor.Summary), @@ -74,6 +78,9 @@ namespace PrivaPub.Api.Mastodon.Mappers if (withSource) account.Source = new Source { + Privacy = actor.Settings.DefaultVisibility ?? "public", + Sensitive = actor.Settings.DefaultSensitive, + Language = actor.Settings.DefaultLanguage, Note = actor.Summary ?? string.Empty, Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), FollowRequestsCount = (int)await DB.Default.CountAsync(f => f.LocalActorId == actor.Id && !f.IsAccepted, token) diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index 9906660..6d575f9 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -26,6 +26,9 @@ namespace PrivaPub.Domain.Social Task Follow(string rootUserId, FollowForm form, CancellationToken token); Task Unfollow(string rootUserId, FollowForm form, CancellationToken token); Task Followings(string rootUserId, string avatarId, CancellationToken token); + Task FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token); + Task UnfollowAs(LocalActor follower, string target, CancellationToken token); + Task Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token); } public class FollowService : IFollowService @@ -56,47 +59,9 @@ namespace PrivaPub.Domain.Social var follower = await OwnedAvatar(rootUserId, form.AvatarId, token); if (follower == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); - - var (local, remote) = await ResolveTarget(form.Target, token); - if (local == default && remote == default) + var following = await FollowAs(follower, form.Target, true, token); + if (following == default) return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound); - if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application)) - return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound); - - var targetUri = local?.Uri ?? remote.ActorURI; - var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); - if (existing != default) - { - result.Data = ToView(existing); - return result; - } - - var following = new Following - { - AvatarId = follower.Id, - TargetActorURI = targetUri, - TargetAccountId = local?.Id ?? remote.ID, - TargetIsLocal = local != default, - TargetInboxURL = local?.Inbox ?? remote.InboxURL, - State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested - }; - following.ID = (string)following.GenerateNewID(); - following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}"); - try - { - await DB.Default.SaveAsync(following, token); - } - catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) - { - result.Data = ToView(await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token)); - return result; - } - - if (local != default) - await FollowLocally(follower, local, following, token); - else - await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token); - result.Data = ToView(following); return result; } @@ -115,33 +80,7 @@ namespace PrivaPub.Domain.Social var follower = await OwnedAvatar(rootUserId, form.AvatarId, token); if (follower == default) return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); - - var (local, remote) = await ResolveTarget(form.Target, token); - var targetUri = local?.Uri ?? remote?.ActorURI ?? form.Target; - var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); - if (following == default) - return result; - - await DB.Default.DeleteAsync(following.ID); - if (following.TargetIsLocal) - { - await DB.Default.DeleteAsync(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri); - await DB.Default.Update().MatchID(following.TargetAccountId) - .Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member)) - .ExecuteAsync(token); - } - else - { - var undo = new JsonObject - { - ["@context"] = ActivityPubRenderer.ActivityStreams, - ["id"] = follower.ActivityUri($"undo-follow-{following.ID}"), - ["type"] = "Undo", - ["actor"] = follower.Uri, - ["object"] = FollowActivity(follower, following) - }; - await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token); - } + await UnfollowAs(follower, form.Target, token); return result; } catch (Exception ex) @@ -151,6 +90,131 @@ namespace PrivaPub.Domain.Social } } + public async Task FollowAs(LocalActor follower, string target, bool showReblogs, CancellationToken token) + { + var (local, remote) = await ResolveTarget(target, token); + if (local == default && remote == default) + return default; + if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application)) + return default; + + var targetUri = local?.Uri ?? remote.ActorURI; + var existing = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); + if (existing != default) + { + if (existing.ShowReblogs != showReblogs) + await DB.Default.Update().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token); + existing.ShowReblogs = showReblogs; + return existing; + } + + var following = new Following + { + AvatarId = follower.Id, + TargetActorURI = targetUri, + TargetAccountId = local?.Id ?? remote.ID, + TargetIsLocal = local != default, + TargetInboxURL = local?.Inbox ?? remote.InboxURL, + ShowReblogs = showReblogs, + State = local is { ManuallyApprovesFollowers: false } ? FollowState.Accepted : FollowState.Requested + }; + following.ID = (string)following.GenerateNewID(); + following.FollowActivityURI = follower.ActivityUri($"follow-{following.ID}"); + try + { + await DB.Default.SaveAsync(following, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); + } + + if (local != default) + await FollowLocally(follower, local, following, token); + else + await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, following), token); + return following; + } + + public async Task UnfollowAs(LocalActor follower, string target, CancellationToken token) + { + var (local, remote) = await ResolveTarget(target, token); + var targetUri = local?.Uri ?? remote?.ActorURI ?? target; + var following = await _dbEntities.Followings.Match(f => f.AvatarId == follower.Id && f.TargetActorURI == targetUri).ExecuteFirstAsync(token); + if (following == default) + return; + + await DB.Default.DeleteAsync(following.ID); + if (following.TargetIsLocal) + { + await DB.Default.DeleteAsync(f => f.LocalActorId == following.TargetAccountId && f.ActorURI == follower.Uri); + await DB.Default.Update().MatchID(following.TargetAccountId) + .Modify(b => b.PullFilter(g => g.Members, m => !m.IsForeign && m.AvatarId == follower.Id && m.Role == GroupRole.Member)) + .ExecuteAsync(token); + return; + } + var undo = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = follower.ActivityUri($"undo-follow-{following.ID}"), + ["type"] = "Undo", + ["actor"] = follower.Uri, + ["object"] = FollowActivity(follower, following) + }; + await _delivery.Enqueue(follower, new[] { following.TargetInboxURL }, undo, token); + } + + public async Task Decide(LocalActor me, string followerAccountId, bool accept, CancellationToken token) + { + var localFollower = await _localActors.FindById(LocalActorKind.Person, followerAccountId, token); + var remoteFollower = localFollower == default ? await _dbEntities.ForeignAvatars.MatchID(followerAccountId).ExecuteFirstAsync(token) : default; + var followerUri = localFollower?.Uri ?? remoteFollower?.ActorURI; + if (followerUri == default) + return false; + var request = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.LocalActorKind == me.Kind && f.ActorURI == followerUri && !f.IsAccepted) + .ExecuteFirstAsync(token); + if (request == default) + return false; + + if (accept) + await DB.Default.Update().MatchID(request.ID).Modify(f => f.IsAccepted, true).ExecuteAsync(token); + else + await DB.Default.DeleteAsync(request.ID); + + if (localFollower != default) + { + if (accept) + await DB.Default.Update().Match(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri) + .Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + else + await DB.Default.DeleteAsync(f => f.AvatarId == localFollower.Id && f.TargetActorURI == me.Uri); + } + else + { + var follow = new JsonObject + { + ["id"] = request.FollowActivityURI, + ["type"] = "Follow", + ["actor"] = followerUri, + ["object"] = me.Uri + }; + var answer = accept + ? ActivityPubRenderer.Accept(me, follow, $"accept-{request.ID}-{DateTime.UtcNow.Ticks}") + : new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = me.ActivityUri($"reject-{request.ID}-{DateTime.UtcNow.Ticks}"), + ["type"] = "Reject", + ["actor"] = me.Uri, + ["object"] = follow + }; + await _delivery.Enqueue(me, new[] { request.InboxURL }, answer, token); + } + if (accept) + await Notifications.Add(me.Id, NotificationType.Follow, followerAccountId, followerUri, default, token); + return true; + } + public async Task Followings(string rootUserId, string avatarId, CancellationToken token) { var result = new WebResult(); diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 99d7863..0e6e0d1 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -29,6 +29,9 @@ namespace PrivaPub.Federation.Actors public bool Discoverable { get; init; } = true; public bool ManuallyApprovesFollowers { get; init; } public bool IsFederated { get; init; } = true; + public bool IsBot { get; init; } + public bool Indexable { get; init; } + public AvatarSettings Settings { get; init; } = new(); public DateTime Published { get; init; } public string BaseAddress { get; init; } public IReadOnlyDictionary Fields { get; init; } = new Dictionary(); @@ -195,6 +198,11 @@ namespace PrivaPub.Federation.Actors PublicKeyPem = avatar.PublicKey, Published = avatar.CreatedAt, Fields = avatar.Fields ?? new Dictionary(), + ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true, + Discoverable = avatar.Settings?.IsDiscoverable != false, + IsBot = avatar.Settings?.IsBot == true, + Indexable = avatar.Settings?.IsIndexable == true, + Settings = avatar.Settings ?? new AvatarSettings(), BaseAddress = BaseAddress }; diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index 754f623..9d76089 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -166,7 +166,7 @@ namespace PrivaPub.Federation.Objects : default; static int? Int(JsonNode node) => - node is JsonValue value && value.TryGetValue(out var number) && number is > 0 and < 100_000 ? number : default; + node is JsonValue value && value.TryGetValue(out var number) && number is > 0 and < 100_000 ? number : (int?)null; static string Plain(string value, int max) { diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index 83208e6..e20e595 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -65,6 +65,7 @@ namespace PrivaPub.Federation.Rendering { LocalActorKind.Group => "Group", LocalActorKind.Application => "Application", + _ when actor.IsBot => "Service", _ => "Person" }, ["preferredUsername"] = actor.UserName, @@ -78,7 +79,7 @@ namespace PrivaPub.Federation.Rendering ["published"] = Day(actor.Published), ["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers, ["discoverable"] = actor.Discoverable, - ["indexable"] = false, + ["indexable"] = actor.Indexable, ["webfinger"] = actor.Handle, ["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox }, ["publicKey"] = new JsonObject diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 141d25e..7a22bbb 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -82,6 +82,11 @@ namespace PrivaPub.Infrastructure.Data .Option(o => o.Unique = true) .CreateAsync(token); await Plain(token, f => f.ActivityURI); + await DB.Default.Index() + .Key(m => m.AvatarId, KeyType.Ascending) + .Key(m => m.Timeline, KeyType.Ascending) + .Option(o => o.Unique = true) + .CreateAsync(token); await Unique(b => b.Domain, Builders.Filter.Type(b => b.Domain, BsonType.String), token); await Unique(i => i.Host, Builders.Filter.Type(i => i.Host, BsonType.String), token); } diff --git a/PrivaPub/Models/Social/Marker.cs b/PrivaPub/Models/Social/Marker.cs new file mode 100644 index 0000000..d79997e --- /dev/null +++ b/PrivaPub/Models/Social/Marker.cs @@ -0,0 +1,13 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class Marker : Entity + { + public string AvatarId { get; set; } + public string Timeline { get; set; } + public string LastReadId { get; set; } + public int Version { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index 488c739..3bb3fdd 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -93,6 +93,15 @@ namespace PrivaPub.Models.User public bool ThemeIsDarkMode { get; set; } = false; public bool ThemeIsDarkGray { get; set; } = false; public bool ThemeIsLightGray { get; set; } = true; + + public bool IsLocked { get; set; } + public bool IsBot { get; set; } + public bool IsDiscoverable { get; set; } = true; + public bool IsIndexable { get; set; } + public bool HideCollections { get; set; } = true; + public string DefaultVisibility { get; set; } = "public"; + public bool DefaultSensitive { get; set; } + public string DefaultLanguage { get; set; } } public enum AvatarServer diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 1f98cc8..2d72918 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -57,6 +57,7 @@ try .PrivaPubRateLimiting() .PrivaPubOAuth(builder.Environment) .AddScoped() + .AddScoped() .PrivaPubMiddlewareConfiguration(); } catch (Exception ex)