diff --git a/CLAUDE.md b/CLAUDE.md index 68aa35a..9c8e0b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,6 +272,11 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. 6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays, because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. + The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default, + it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops + what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days, + unasked private mentions and silenced accounts send; accepting a request lets that account in for good + (`NotificationPermission`). 7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt, cover, the author's source, link, video, audio and event details, a remote post's own place (Pixelfed's `location`, never federated again), and up/down votes. Every media URL in it goes diff --git a/PrivaPub.Tests/Http/NotificationPolicyTests.cs b/PrivaPub.Tests/Http/NotificationPolicyTests.cs new file mode 100644 index 0000000..c65486d --- /dev/null +++ b/PrivaPub.Tests/Http/NotificationPolicyTests.cs @@ -0,0 +1,119 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; + +namespace PrivaPub.Tests.Http +{ + // Mastodon's notification policy and requests (4.3): accepted by default; a policy filters what strangers, new accounts + // or unasked private mentions send into a request per account, which the persona lets in (and that account from then + // on) or dismisses; or drops it + [Trait("Category", "Integration")] + public sealed class NotificationPolicyTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static async Task> Notifiers(Mastodon persona, string query = "") => + (await persona.Client.Get("/api/v1/notifications" + query)).Ok().Array.Select(n => n!["account"].Text("id")); + + [Fact] + public async Task Everything_is_accepted_until_the_persona_chooses() + { + var alice = await _host.Mastodon("alice"); + var stranger = await _host.Mastodon("stranger"); + var post = (await alice.Status("like me")).Text("id"); + + var policy = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body; + (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); + + Assert.Equal(["accept"], new[] { "for_not_following", "for_not_followers", "for_new_accounts", "for_private_mentions", "for_limited_accounts" } + .Select(key => policy.Text(key)).Distinct()); + Assert.Contains(stranger.Id, await Notifiers(alice)); + Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); + } + + [Fact] + public async Task A_strangers_notifications_wait_in_a_request_which_lets_them_and_the_stranger_in() + { + var alice = await _host.Mastodon("alice"); + var friend = await _host.Mastodon("friend"); + var stranger = await _host.Mastodon("stranger"); + (await alice.Client.Post($"/api/v1/accounts/{friend.Id}/follow")).Ok(); + var post = (await alice.Status("like me")).Text("id"); + var policy = (await alice.Client.Patch("/api/v2/notifications/policy", ("for_not_following", "filter"))).Ok().Body; + Assert.Equal("filter", policy.Text("for_not_following")); + + (await friend.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); + (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); + + Assert.Equal([friend.Id], await Notifiers(alice)); + Assert.Equal(1, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body["count"]!.GetValue()); + Assert.DoesNotContain(stranger.Id, (await alice.Client.Get("/api/v2/notifications")).Ok().Body["accounts"]!.AsArray().Select(a => a.Text("id"))); + var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!; + Assert.Equal((stranger.Id, "1", post), (request["account"].Text("id"), request.Text("notifications_count"), request["last_status"].Text("id"))); + var summary = (await alice.Client.Get("/api/v2/notifications/policy")).Ok().Body["summary"]!; + Assert.Equal((1, 1), (summary["pending_requests_count"]!.GetValue(), summary["pending_notifications_count"]!.GetValue())); + Assert.Equal([stranger.Id], await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true")); + + (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/accept")).Ok(); + + Assert.Contains(stranger.Id, await Notifiers(alice)); + Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); + var again = (await alice.Status("like me again")).Text("id"); + (await stranger.Client.Post($"/api/v1/statuses/{again}/favourite")).Ok(); + Assert.Equal(2, (await Notifiers(alice, "?types[]=favourite")).Count(id => id == stranger.Id)); + } + + [Fact] + public async Task A_dismissed_request_takes_its_notifications_with_it_and_a_dropped_one_is_never_kept() + { + var alice = await _host.Mastodon("alice"); + var stranger = await _host.Mastodon("stranger"); + var newcomer = await _host.Mastodon("newcomer"); + var post = (await alice.Status("like me")).Text("id"); + // (v1 speaks of filtering only) + Assert.True((await alice.Client.Put("/api/v1/notifications/policy", ("filter_not_following", "true"))).Ok().Body.Flag("filter_not_following")); + (await stranger.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); + var request = Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array)!; + + (await alice.Client.Post($"/api/v1/notifications/requests/{request.Text("id")}/dismiss")).Ok(); + + Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); + Assert.Empty(await Notifiers(alice, $"?account_id={stranger.Id}&include_filtered=true")); + Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/requests/{request.Text("id")}")).Status); + + (await alice.Client.Patch("/api/v2/notifications/policy", ("for_new_accounts", "drop"))).Ok(); + (await newcomer.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok(); + Assert.Empty(await Notifiers(alice, $"?account_id={newcomer.Id}&include_filtered=true")); + Assert.Empty((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); + } + + [Fact] + public async Task An_unasked_private_mention_is_filtered_and_one_answering_the_persona_is_not() + { + var alice = await _host.Mastodon("alice"); + var stranger = await _host.Mastodon("stranger"); + (await alice.Client.Patch("/api/v2/notifications/policy", ("for_private_mentions", "filter"))).Ok(); + var asked = (await alice.Status($"@{stranger.UserName} write to me", ("visibility", "direct"))).Text("id"); + + await stranger.Status($"@{alice.UserName} hello out of nowhere", ("visibility", "direct")); + await stranger.Status($"@{alice.UserName} as you asked", ("visibility", "direct"), ("in_reply_to_id", asked)); + + var mentions = (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n!["status"].Text("content")).ToList(); + Assert.Contains(mentions, c => c.Contains("as you asked")); + Assert.DoesNotContain(mentions, c => c.Contains("out of nowhere")); + Assert.Single((await alice.Client.Get("/api/v1/notifications/requests")).Ok().Array); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs new file mode 100644 index 0000000..7cffccc --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/NotificationRequestsController.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Social; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + // Mastodon's notification policy and requests (4.3): what the persona's notifications from strangers, new accounts, + // unasked private mentions and silenced accounts become, and the requests that gather the filtered ones, one per + // account, let in or dismissed + public class NotificationRequestsController : MastodonController + { + readonly MastodonMapper _mapper; + readonly DbEntities _dbEntities; + + public NotificationRequestsController(MastodonMapper mapper, DbEntities dbEntities) + { + _mapper = mapper; + _dbEntities = dbEntities; + } + + static string Name(NotificationAction action) => action switch + { + NotificationAction.Filter => "filter", + NotificationAction.Drop => "drop", + _ => "accept" + }; + + static NotificationAction? Parse(string value) => value?.ToLowerInvariant() switch + { + "accept" => NotificationAction.Accept, + "filter" => NotificationAction.Filter, + "drop" => NotificationAction.Drop, + _ => null + }; + + async Task Summary(CancellationToken token) => new + { + pending_requests_count = await DB.Default.CountAsync(r => r.AvatarId == MyId, token), + pending_notifications_count = await DB.Default.CountAsync(n => n.AvatarId == MyId && n.Filtered, token) + }; + + async Task V2(NotificationPolicy policy, CancellationToken token) => new + { + for_not_following = Name(policy.ForNotFollowing), + for_not_followers = Name(policy.ForNotFollowers), + for_new_accounts = Name(policy.ForNewAccounts), + for_private_mentions = Name(policy.ForPrivateMentions), + for_limited_accounts = Name(policy.ForLimitedAccounts), + summary = await Summary(token) + }; + + // the first version spoke of filtering only: dropped is filtered for it + async Task V1(NotificationPolicy policy, CancellationToken token) => new + { + filter_not_following = policy.ForNotFollowing != NotificationAction.Accept, + filter_not_followers = policy.ForNotFollowers != NotificationAction.Accept, + filter_new_accounts = policy.ForNewAccounts != NotificationAction.Accept, + filter_private_mentions = policy.ForPrivateMentions != NotificationAction.Accept, + summary = await Summary(token) + }; + + [HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")] + public async Task Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token)); + + [HttpPatch("/api/v2/notifications/policy"), Scope("write:notifications")] + public async Task SetPolicy(CancellationToken token) + { + var policy = await NotificationPolicies.Of(MyId, token); + policy.ForNotFollowing = Parse(Params.Get("for_not_following")) ?? policy.ForNotFollowing; + policy.ForNotFollowers = Parse(Params.Get("for_not_followers")) ?? policy.ForNotFollowers; + policy.ForNewAccounts = Parse(Params.Get("for_new_accounts")) ?? policy.ForNewAccounts; + policy.ForPrivateMentions = Parse(Params.Get("for_private_mentions")) ?? policy.ForPrivateMentions; + policy.ForLimitedAccounts = Parse(Params.Get("for_limited_accounts")) ?? policy.ForLimitedAccounts; + await NotificationPolicies.Save(policy, token); + return Json(await V2(policy, token)); + } + + [HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")] + public async Task PolicyV1(CancellationToken token) => Json(await V1(await NotificationPolicies.Of(MyId, token), token)); + + [HttpPut("/api/v1/notifications/policy"), HttpPatch("/api/v1/notifications/policy"), Scope("write:notifications")] + public async Task SetPolicyV1(CancellationToken token) + { + var policy = await NotificationPolicies.Of(MyId, token); + static NotificationAction Filter(bool? filter, NotificationAction current) => + filter switch { true => current == NotificationAction.Drop ? NotificationAction.Drop : NotificationAction.Filter, false => NotificationAction.Accept, _ => current }; + policy.ForNotFollowing = Filter(Params.Bool("filter_not_following"), policy.ForNotFollowing); + policy.ForNotFollowers = Filter(Params.Bool("filter_not_followers"), policy.ForNotFollowers); + policy.ForNewAccounts = Filter(Params.Bool("filter_new_accounts"), policy.ForNewAccounts); + policy.ForPrivateMentions = Filter(Params.Bool("filter_private_mentions"), policy.ForPrivateMentions); + await NotificationPolicies.Save(policy, token); + return Json(await V1(policy, token)); + } + + async Task Map(NotificationRequest request, CancellationToken token) + { + var account = await _mapper.Account(request.FromAccountId, token); + if (account == default) + return default; + var post = string.IsNullOrEmpty(request.LastPostId) ? default + : await _dbEntities.Posts.Match(p => p.ID == request.LastPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); + return new + { + id = request.ID, + created_at = MastodonJson.Time(request.CreatedAt), + updated_at = MastodonJson.Time(request.UpdatedAt), + notifications_count = request.NotificationsCount.ToString(System.Globalization.CultureInfo.InvariantCulture), + account, + last_status = post == default ? default : await _mapper.Status(post, MyId, token) + }; + } + + [HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")] + public async Task Requests(CancellationToken token) + { + var requests = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().Match(r => r.AvatarId == MyId), r => r.ID, token); + Link("/api/v1/notifications/requests", requests.LastOrDefault()?.ID, requests.FirstOrDefault()?.ID); + var mapped = new List(); + foreach (var request in requests) + if (await Map(request, token) is { } one) + mapped.Add(one); + return Json(mapped); + } + + Task Mine(string id, CancellationToken token) => + DB.Default.Find().Match(r => r.ID == id && r.AvatarId == MyId).ExecuteFirstAsync(token); + + [HttpGet("/api/v1/notifications/requests/merged"), Scope("read:notifications")] + public IActionResult Merged() => Json(new { merged = true }); + + [HttpGet("/api/v1/notifications/requests/{id}"), Scope("read:notifications")] + public async Task Request(string id, CancellationToken token) => + await Mine(id, token) is { } request && await Map(request, token) is { } mapped ? Json(mapped) : NotFoundError(); + + [HttpPost("/api/v1/notifications/requests/{id}/accept"), Scope("write:notifications")] + public async Task Accept(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } request) + return NotFoundError(); + await NotificationPolicies.Accept(request, token); + return Json(new { }); + } + + [HttpPost("/api/v1/notifications/requests/{id}/dismiss"), Scope("write:notifications")] + public async Task Dismiss(string id, CancellationToken token) + { + if (await Mine(id, token) is not { } request) + return NotFoundError(); + await NotificationPolicies.Dismiss(request, token); + return Json(new { }); + } + + [HttpPost("/api/v1/notifications/requests/accept"), Scope("write:notifications")] + public async Task AcceptMany(CancellationToken token) + { + foreach (var id in Params.List("id").Take(100)) + if (await Mine(id, token) is { } request) + await NotificationPolicies.Accept(request, token); + return Json(new { }); + } + + [HttpPost("/api/v1/notifications/requests/dismiss"), Scope("write:notifications")] + public async Task DismissMany(CancellationToken token) + { + foreach (var id in Params.List("id").Take(100)) + if (await Mine(id, token) is { } request) + await NotificationPolicies.Dismiss(request, token); + return Json(new { }); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index fefea75..81d30f3 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -182,29 +182,5 @@ namespace PrivaPub.Api.Mastodon.Controllers + "together. What a persona posts is shared as its visibility says; a located post never leaves this server. " + "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.

" }); - - [HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")] - public IActionResult NotificationPolicyV1() => Json(new - { - filter_not_following = false, - filter_not_followers = false, - filter_new_accounts = false, - filter_private_mentions = false, - summary = new { pending_requests_count = 0, pending_notifications_count = 0 } - }); - - [HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")] - public IActionResult NotificationPolicyV2() => Json(new - { - for_not_following = "accept", - for_not_followers = "accept", - for_new_accounts = "accept", - for_private_mentions = "accept", - for_limited_accounts = "accept", - summary = new { pending_requests_count = 0, pending_notifications_count = 0 } - }); - - [HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")] - public IActionResult NotificationRequests() => Json(Array.Empty()); } } diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index 0222b08..67d55de 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; @@ -231,6 +232,9 @@ namespace PrivaPub.Api.Mastodon.Controllers 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); + // what the persona's notification policy filtered stays in its requests, unless asked for (a request's own page) + if (Params.Bool("include_filtered") != true) + query.Match(NotFiltered); if (types.Count > 0) query.Match(n => types.Contains(n.Type)); if (excluded.Count > 0) @@ -240,6 +244,9 @@ namespace PrivaPub.Api.Mastodon.Controllers return query; } + // (notifications made before the policy have no Filtered at all) + static readonly FilterDefinition NotFiltered = Builders.Filter.Ne(n => n.Filtered, true); + [HttpGet("/api/v1/notifications"), Scope("read:notifications")] public async Task List(CancellationToken token) { @@ -283,7 +290,7 @@ namespace PrivaPub.Api.Mastodon.Controllers async Task> Members(string key, CancellationToken token) => InGroup(key) is { } filter - ? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token) + ? await _dbEntities.Notifications.Match(filter).Match(NotFiltered).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token) : new List(); async Task Groups(List page, ISet grouped, CancellationToken token) @@ -294,7 +301,8 @@ namespace PrivaPub.Api.Mastodon.Controllers foreach (var group in shown.GroupBy(n => GroupKey(n, grouped))) { var newest = mapped[group.First().ID]; - var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 : await DB.Default.CountAsync(InGroup(group.Key), token); + var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 + : await DB.Default.CountAsync(Builders.Filter.And(Builders.Filter.Where(InGroup(group.Key)), NotFiltered), token); groups.Add(new { group_key = group.Key, @@ -390,7 +398,7 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")] public async Task UnreadCount(CancellationToken token) { - var unread = await DB.Default.Find().Match(n => n.AvatarId == MyId && !n.IsRead) + var unread = await DB.Default.Find().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered) .Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token); return Json(new { count = (await Map(unread, token)).Count }); } diff --git a/PrivaPub/Domain/Social/NotificationPolicies.cs b/PrivaPub/Domain/Social/NotificationPolicies.cs new file mode 100644 index 0000000..a66b3d7 --- /dev/null +++ b/PrivaPub/Domain/Social/NotificationPolicies.cs @@ -0,0 +1,137 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Domain.Social +{ + // A persona's notification policy (Mastodon 4.3), applied as each notification is made: accepted, filtered into a + // request per account, or dropped. Polls ending and edits of posts the persona answered are never filtered. + public static class NotificationPolicies + { + static readonly TimeSpan NewAccount = TimeSpan.FromDays(30); + // someone who followed the persona lately is not yet a follower for the policy: following, then writing, is how + // a newcomer gets past it + static readonly TimeSpan NewFollower = TimeSpan.FromDays(3); + + public static async Task Of(string avatarId, CancellationToken token) => + await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token) + ?? new NotificationPolicy { AvatarId = avatarId }; + + public static async Task Save(NotificationPolicy policy, CancellationToken token) => + await DB.Default.Update().Match(p => p.AvatarId == policy.AvatarId) + .Modify(p => p.ForNotFollowing, policy.ForNotFollowing) + .Modify(p => p.ForNotFollowers, policy.ForNotFollowers) + .Modify(p => p.ForNewAccounts, policy.ForNewAccounts) + .Modify(p => p.ForPrivateMentions, policy.ForPrivateMentions) + .Modify(p => p.ForLimitedAccounts, policy.ForLimitedAccounts) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); + + public static async Task Judge(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, + CancellationToken token) + { + if (type is NotificationType.Poll or NotificationType.Update || string.IsNullOrEmpty(fromActorUri)) + return NotificationAction.Accept; + var policy = await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token); + if (policy is null or { ForNotFollowing: NotificationAction.Accept, ForNotFollowers: NotificationAction.Accept, ForNewAccounts: NotificationAction.Accept, + ForPrivateMentions: NotificationAction.Accept, ForLimitedAccounts: NotificationAction.Accept }) + return NotificationAction.Accept; + if (await DB.Default.Find().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token)) + return NotificationAction.Accept; + + var actions = new List(); + var following = await DB.Default.Find() + .Match(f => f.AvatarId == avatarId && f.TargetActorURI == fromActorUri && f.State == FollowState.Accepted).ExecuteAnyAsync(token); + if (!following) + actions.Add(policy.ForNotFollowing); + if (policy.ForNotFollowers != NotificationAction.Accept) + { + var since = DateTime.UtcNow - NewFollower; + if (!await DB.Default.Find() + .Match(f => f.LocalActorId == avatarId && f.ActorURI == fromActorUri && f.IsAccepted && f.CreationDate <= since).ExecuteAnyAsync(token)) + actions.Add(policy.ForNotFollowers); + } + if (policy.ForNewAccounts != NotificationAction.Accept || policy.ForLimitedAccounts != NotificationAction.Accept) + { + var (since, silenced) = await Sender(fromAccountId, fromActorUri, token); + if (since > DateTime.UtcNow - NewAccount) + actions.Add(policy.ForNewAccounts); + if (silenced) + actions.Add(policy.ForLimitedAccounts); + } + // a private mention it did not ask for: from someone it does not follow, answering nothing of the persona's + if (policy.ForPrivateMentions != NotificationAction.Accept && type == NotificationType.Mention && !following && !string.IsNullOrEmpty(postId) + && await DB.Default.Find().Match(p => p.ID == postId && p.Visibility == PostVisibility.Direct && p.InReplyToAccountId != avatarId) + .ExecuteAnyAsync(token)) + actions.Add(policy.ForPrivateMentions); + return actions.Contains(NotificationAction.Drop) ? NotificationAction.Drop + : actions.Contains(NotificationAction.Filter) ? NotificationAction.Filter + : NotificationAction.Accept; + } + + // since when the sender has been on the fediverse (its actor's published, else when this server first met it), and + // whether it is silenced here, itself or its server + static async Task<(DateTime Since, bool Silenced)> Sender(string accountId, string actorUri, CancellationToken token) + { + var host = Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) ? uri.Host : default; + var silencedHere = host != default && (await DB.Default.Find().Match(b => b.Severity == DomainBlockSeverity.Silence).ExecuteAsync(token)) + .Any(b => host.Equals(b.Domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + b.Domain, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(accountId) && await DB.Default.Find().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local) + return (local.CreatedAt, local.SilencedAt.HasValue); + var foreign = await DB.Default.Find().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); + return foreign == default ? (DateTime.UtcNow, silencedHere) : (foreign.Published ?? foreign.CreatedAt, silencedHere || foreign.SilencedAt.HasValue); + } + + // a notification filtered from an account: its request gathers it + public static async Task Requested(Notification notification, CancellationToken token) + { + var now = DateTime.UtcNow; + var update = DB.Default.Update() + .Match(r => r.AvatarId == notification.AvatarId && r.FromActorURI == notification.FromActorURI) + .Modify(b => b.Inc(r => r.NotificationsCount, 1)) + .Modify(r => r.FromAccountId, notification.FromAccountId) + .Modify(r => r.UpdatedAt, now) + .Modify(b => b.SetOnInsert(r => r.CreatedAt, now)) + .Option(o => o.IsUpsert = true); + if (!string.IsNullOrEmpty(notification.PostId)) + update.Modify(r => r.LastPostId, notification.PostId); + try + { + await update.ExecuteAsync(token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // (two filtered at once from one account: the other made the request) + await update.Option(o => o.IsUpsert = false).ExecuteAsync(token); + } + } + + // the persona lets an account's notifications in, now and from then on + public static async Task Accept(NotificationRequest request, CancellationToken token) + { + try + { + await DB.Default.SaveAsync(new NotificationPermission { AvatarId = request.AvatarId, FromActorURI = request.FromActorURI }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + } + await DB.Default.Update().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered) + .Modify(n => n.Filtered, false).ExecuteAsync(token); + await DB.Default.DeleteAsync(request.ID); + } + + // or dismisses them + public static async Task Dismiss(NotificationRequest request, CancellationToken token) + { + await DB.Default.DeleteAsync(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered); + await DB.Default.DeleteAsync(request.ID); + } + } +} diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs index 12ad1a8..52d35a9 100644 --- a/PrivaPub/Domain/Social/Notifications.cs +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -14,6 +14,9 @@ namespace PrivaPub.Domain.Social return; if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0) return; + var action = await NotificationPolicies.Judge(avatarId, type, fromAccountId, fromActorUri, postId, token); + if (action == NotificationAction.Drop) + return; try { var notification = new Notification @@ -25,9 +28,16 @@ namespace PrivaPub.Domain.Social PostId = postId, Emoji = emoji, EmojiURL = emojiUrl, - DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}" + DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}", + Filtered = action == NotificationAction.Filter }; await DB.Default.SaveAsync(notification, token); + // filtered: in its account's request, told to no stream + if (notification.Filtered) + { + await NotificationPolicies.Requested(notification, token); + return; + } var told = new Timelines.Streams.Event("notification", notification.ID); Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told); Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told); diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 8b1e668..1ad3218 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -124,7 +124,10 @@ namespace PrivaPub.Infrastructure.Data (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"), (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"), (() => DB.Default.Index().Key(r => r.Configured, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "relay"), - (() => DB.Default.Index().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured") + (() => DB.Default.Index().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured"), + (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification policy"), + (() => DB.Default.Index().Key(r => r.AvatarId, KeyType.Ascending).Key(r => r.FromActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification request"), + (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.FromActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "notification permission") }) await pair.Item1(); await Plain(token, b => b.TargetActorURI); diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs index deedb6e..5600402 100644 --- a/PrivaPub/Models/Social/Notification.cs +++ b/PrivaPub/Models/Social/Notification.cs @@ -13,6 +13,7 @@ namespace PrivaPub.Models.Social public string Emoji { get; set; }//a reaction's emoji public string EmojiURL { get; set; } public bool IsRead { get; set; } + public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/PrivaPub/Models/Social/NotificationPolicy.cs b/PrivaPub/Models/Social/NotificationPolicy.cs new file mode 100644 index 0000000..0d2939a --- /dev/null +++ b/PrivaPub/Models/Social/NotificationPolicy.cs @@ -0,0 +1,45 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + // Mastodon's notification policy (4.3): what becomes of a notification from an account the persona does not follow, one + // that does not follow it (or only lately), one new to the fediverse, a private mention it did not ask for, or an + // account on a server this one silences. Anything not accepted is filtered (kept, out of the notifications, in a + // request per account) or dropped. Accepted by default, so nothing changes until a persona chooses + public class NotificationPolicy : Entity + { + public string AvatarId { get; set; } + public NotificationAction ForNotFollowing { get; set; } = NotificationAction.Accept; + public NotificationAction ForNotFollowers { get; set; } = NotificationAction.Accept; + public NotificationAction ForNewAccounts { get; set; } = NotificationAction.Accept; + public NotificationAction ForPrivateMentions { get; set; } = NotificationAction.Accept; + public NotificationAction ForLimitedAccounts { get; set; } = NotificationAction.Accept; + } + + public enum NotificationAction + { + Accept, + Filter, + Drop + } + + // the notifications filtered from one account, waiting for the persona to let them in or dismiss them + public class NotificationRequest : Entity + { + public string AvatarId { get; set; } + public string FromAccountId { get; set; } + public string FromActorURI { get; set; } + public string LastPostId { get; set; } + public int NotificationsCount { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } + + // an account whose notifications the persona let in: never filtered again + public class NotificationPermission : Entity + { + public string AvatarId { get; set; } + public string FromActorURI { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +}