From 0028e96e76a789d4034fb43acdc82f8c734d7ea4 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 12:05:58 +0200 Subject: [PATCH] The Mastodon client API: accounts, statuses, timelines, notifications, search With a token for a persona, a Mastodon client can now: - accounts: verify/update credentials (display name, note, fields, locked, bot, discoverable, indexable, hide collections, posting defaults; the change federates as Update{Person}), get, lookup, statuses (paged, by visibility to the viewer), relationships, search, follow and unfollow, follow requests (authorize and reject answer remote followers with Accept or Reject), remove from followers. Followers and following lists are shown to their owner only. - statuses: post (plain text, mentions, hashtags, replies, content warnings, the four visibilities, Idempotency-Key), get, edit (PUT), delete returning the source for redrafting, context, history, source, favourite, reblog and their undos, favourited_by and reblogged_by. - timelines: home, public (local or remote), tag; favourites; conversations; markers. - notifications: list with types and exclude_types, get, dismiss, clear, unread count. - /api/v2/search, resolving a handle or a URL to an account or a post. Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and push answer empty lists or a 422 saying they are not supported yet, so clients degrade instead of failing. Public reads work without a token. Persona settings (locked, bot, indexable, discoverable) now also shape the ActivityPub actor. Fixed on the way: three conditional expressions whose `default` was the value type's, so a missing limit became 1, a missing flag became false and an attachment without dimensions became 0x0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- PrivaPub.Tests/Api/MastodonParamsTests.cs | 82 ++++++ .../Controllers/AccountsController.cs | 278 +++++++++++++++++- .../Mastodon/Controllers/SearchController.cs | 149 ++++++++++ .../Controllers/StatusesController.cs | 276 +++++++++++++++++ .../Controllers/TimelinesController.cs | 243 +++++++++++++++ .../Mastodon/Infrastructure/MastodonParams.cs | 4 +- .../Api/Mastodon/Infrastructure/Paging.cs | 30 ++ .../Api/Mastodon/Mappers/AccountSearch.cs | 55 ++++ .../Api/Mastodon/Mappers/MastodonMapper.cs | 7 + PrivaPub/Domain/Social/FollowService.cs | 198 ++++++++----- .../Federation/Actors/LocalActorService.cs | 8 + PrivaPub/Federation/Objects/NoteParser.cs | 2 +- .../Rendering/ActivityPubRenderer.cs | 3 +- PrivaPub/Infrastructure/Data/Indexes.cs | 5 + PrivaPub/Models/Social/Marker.cs | 13 + PrivaPub/Models/User/Avatar.cs | 9 + PrivaPub/Program.cs | 1 + 17 files changed, 1290 insertions(+), 73 deletions(-) create mode 100644 PrivaPub.Tests/Api/MastodonParamsTests.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/SearchController.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/StatusesController.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs create mode 100644 PrivaPub/Api/Mastodon/Infrastructure/Paging.cs create mode 100644 PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs create mode 100644 PrivaPub/Models/Social/Marker.cs 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)