diff --git a/PrivaPub.Tests/Domain/RelationshipTests.cs b/PrivaPub.Tests/Domain/RelationshipTests.cs new file mode 100644 index 0000000..c5ac8bd --- /dev/null +++ b/PrivaPub.Tests/Domain/RelationshipTests.cs @@ -0,0 +1,122 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Post; +using PrivaPub.ClientModels.Social; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Domain +{ + [Trait("Category", "Integration")] + public sealed class RelationshipTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + [Fact] + public async Task A_block_cuts_follows_both_ways_and_hides_the_account() + { + var token = TestContext.Current.CancellationToken; + var (aliceRoot, alice) = await _harness.Persona("alice"); + var (bobRoot, bob) = await _harness.Persona("bob"); + await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token); + await _harness.Follows.Follow(bobRoot, new FollowForm { AvatarId = bob.Id, Target = alice.UserName }, token); + + await _harness.Relationships.Block(alice, bob.Uri, bob.Id, token); + await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token); + + Assert.False(await DB.Default.Find().Match(f => f.AvatarId == alice.Id || f.AvatarId == bob.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id || f.LocalActorId == bob.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == bob.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(n => n.AvatarId == alice.Id).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_blocked_remote_account_is_refused_when_it_follows() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + await _harness.Relationships.Block(alice, mallory.Id, default, token); + + await _harness.Deliver(mallory, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(mallory)}/follows/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = mallory.Id, ["object"] = alice.Uri + }); + + Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token)); + Assert.Equal("Reject", Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox"))["type"]!.GetValue()); + } + + [Fact] + public async Task A_muted_account_stays_out_of_home_and_notifications() + { + var token = TestContext.Current.CancellationToken; + var (aliceRoot, alice) = await _harness.Persona("alice"); + var (bobRoot, bob) = await _harness.Persona("bob"); + await _harness.Follows.Follow(aliceRoot, new FollowForm { AvatarId = alice.Id, Target = bob.UserName }, token); + await _harness.Relationships.Mute(alice, bob.Uri, bob.Id, hideNotifications: true, default, token); + + await _harness.Posts.InsertPost(bobRoot, new InsertPostForm { AvatarId = bob.Id, Text = $"hey @{alice.UserName}" }, token); + + Assert.False(await DB.Default.Find().Match(e => e.AvatarId == alice.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Mention).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_report_is_forwarded_by_the_instance_not_the_persona() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + var foreign = await _harness.Remote.GetActor(mallory.Id, refresh: false, token); + + var report = await _harness.Reports.File(alice, foreign.ID, Array.Empty(), "spam everywhere", "spam", forward: true, token); + + Assert.True(report.Forwarded); + var flag = Assert.Single(await _harness.Outgoing(mallory.Id + "/inbox")); + Assert.Equal("Flag", flag["type"]!.GetValue()); + Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue()); + Assert.DoesNotContain(alice.UserName, flag.ToJsonString()); + Assert.DoesNotContain(alice.Id, flag.ToJsonString()); + } + + [Fact] + public async Task An_inbound_flag_becomes_a_report_for_moderators() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var reporter = new RemoteActor(_harness.Peer, "reporter"); + await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "bad post" }, token); + var post = await DB.Default.Find().Match(p => p.GroupUserId == alice.Id).ExecuteSingleAsync(token); + + await _harness.Deliver(reporter, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(reporter)}/flags/{Guid.NewGuid():N}", ["type"] = "Flag", ["actor"] = reporter.Id, + ["content"] = "please look", ["object"] = new JsonArray(alice.Uri, post.ObjectURI) + }); + + var report = await DB.Default.Find().Match(r => r.TargetAccountId == alice.Id).ExecuteSingleAsync(token); + Assert.Equal(reporter.Id, report.ReporterActorURI); + Assert.Equal(new[] { post.ID }, report.PostIds); + Assert.Equal("please look", report.Comment); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 9339f6a..29078ed 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Domain.Content; +using PrivaPub.Domain.Relationships; using PrivaPub.Domain.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Timelines; @@ -51,7 +52,8 @@ namespace PrivaPub.Tests.Support new AnnounceHandler(Db, Local, RemotePosts, Fanout), new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts), new DeleteHandler(Db, Local, Remote, Delivery), - new UpdateHandler(Db, Local, Remote) + new UpdateHandler(Db, Local, Remote), + new FlagHandler(Db, Local) }, NullLogger.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Content = new ContentRenderer(Local, Remote); @@ -59,6 +61,8 @@ namespace PrivaPub.Tests.Support Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer(), NullLogger.Instance); Timelines = new TimelineService(Db, new KeyLocalizer()); + Relationships = new RelationshipService(Db, Follows, Delivery); + Reports = new ReportService(Db, Local, Delivery); } public Peer Peer { get; } @@ -77,6 +81,8 @@ namespace PrivaPub.Tests.Support public Fanout Fanout { get; } public RemotePosts RemotePosts { get; } public TimelineService Timelines { get; } + public RelationshipService Relationships { get; } + public ReportService Reports { get; } public async Task FollowedBy(LocalActor local, RemoteActor follower) => await DB.Default.SaveAsync(new Follower diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index f0c53fc..d2a0f41 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -6,6 +6,7 @@ using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; +using PrivaPub.Domain.Relationships; using PrivaPub.Domain.Social; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; @@ -27,10 +28,12 @@ namespace PrivaPub.Api.Mastodon.Controllers readonly IRemoteActorService _remoteActors; readonly IFollowService _follows; readonly IOutboxPublisher _outbox; + readonly IRelationshipService _relationships; public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, - IFollowService follows, IOutboxPublisher outbox) + IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships) { + _relationships = relationships; _mapper = mapper; _dbEntities = dbEntities; _localActors = localActors; @@ -136,7 +139,13 @@ namespace PrivaPub.Api.Mastodon.Controllers if (local == default && remote == default) return NotFoundError(); if (Params.Bool("pinned") == true) - return Json(Array.Empty()); + { + if (local == default) + return Json(Array.Empty()); + var pinIds = (await DB.Default.Find().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList(); + var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token); + return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token)); + } var query = local != default ? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue) @@ -220,6 +229,83 @@ namespace PrivaPub.Api.Mastodon.Controllers return Json(await Relationship(id, token)); } + [HttpPost("/api/v1/accounts/{id}/block"), Scope("write:blocks")] + public async Task Block(string id, CancellationToken token) + { + var (local, remote) = await Find(id, token); + if (local == default && remote == default || local?.Id == MyId) + return NotFoundError(); + await _relationships.Block(Me, local?.Uri ?? remote.ActorURI, id, token); + return Json(await Relationship(id, token)); + } + + [HttpPost("/api/v1/accounts/{id}/unblock"), Scope("write:blocks")] + public async Task Unblock(string id, CancellationToken token) + { + var (local, remote) = await Find(id, token); + if (local == default && remote == default) + return NotFoundError(); + await _relationships.Unblock(Me, local?.Uri ?? remote.ActorURI, token); + return Json(await Relationship(id, token)); + } + + [HttpPost("/api/v1/accounts/{id}/mute"), Scope("write:mutes")] + public async Task Mute(string id, CancellationToken token) + { + var (local, remote) = await Find(id, token); + if (local == default && remote == default || local?.Id == MyId) + return NotFoundError(); + var duration = Params.Int("duration") is { } seconds and > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null; + await _relationships.Mute(Me, local?.Uri ?? remote.ActorURI, id, Params.Bool("notifications") != false, duration, token); + return Json(await Relationship(id, token)); + } + + [HttpPost("/api/v1/accounts/{id}/unmute"), Scope("write:mutes")] + public async Task Unmute(string id, CancellationToken token) + { + var (local, remote) = await Find(id, token); + if (local == default && remote == default) + return NotFoundError(); + await _relationships.Unmute(Me, local?.Uri ?? remote.ActorURI, token); + return Json(await Relationship(id, token)); + } + + [HttpGet("/api/v1/blocks"), Scope("read:blocks")] + public async Task Blocks(CancellationToken token) + { + var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().Match(b => b.AvatarId == MyId), b => b.ID, token); + var accounts = await _mapper.Accounts(blocks.Select(b => b.TargetAccountId), token); + Link("/api/v1/blocks", blocks.LastOrDefault()?.ID, blocks.FirstOrDefault()?.ID); + return Json(blocks.Select(b => accounts.GetValueOrDefault(b.TargetAccountId)).Where(a => a != default).ToList()); + } + + [HttpGet("/api/v1/mutes"), Scope("read:mutes")] + public async Task Mutes(CancellationToken token) + { + var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find().Match(m => m.AvatarId == MyId), m => m.ID, token); + var accounts = await _mapper.Accounts(mutes.Select(m => m.TargetAccountId), token); + Link("/api/v1/mutes", mutes.LastOrDefault()?.ID, mutes.FirstOrDefault()?.ID); + return Json(mutes.Select(m => accounts.GetValueOrDefault(m.TargetAccountId)).Where(a => a != default).ToList()); + } + + [HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")] + public async Task DomainBlocks(CancellationToken token) => + Json((await DB.Default.Find().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList()); + + [HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")] + public async Task BlockDomain(CancellationToken token) + { + await _relationships.BlockDomain(Me, Params.Get("domain"), token); + return Json(new { }); + } + + [HttpDelete("/api/v1/domain_blocks"), Scope("write:blocks")] + public async Task UnblockDomain(CancellationToken token) + { + await _relationships.UnblockDomain(Me, Params.Get("domain"), token); + return Json(new { }); + } + [HttpGet("/api/v1/follow_requests"), Scope("read:follows")] public async Task FollowRequests(CancellationToken token) { @@ -280,9 +366,18 @@ namespace PrivaPub.Api.Mastodon.Controllers var uri = local?.Uri ?? remote?.ActorURI; var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token); var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token); + var blocking = uri != default && await DB.Default.Find().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token); + var mute = uri == default ? default : await DB.Default.Find().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token); + var blockedBy = local != default && await DB.Default.Find().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token); + var domainBlocked = remote != default && await DB.Default.Find().Match(b => b.AvatarId == MyId && b.Domain == remote.Domain).ExecuteAnyAsync(token); return new Relationship { Id = id, + Blocking = blocking, + BlockedBy = blockedBy, + Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow), + MutingNotifications = mute?.HideNotifications == true, + DomainBlocking = domainBlocked, Following = following?.State == FollowState.Accepted, Requested = following?.State == FollowState.Requested, ShowingReblogs = following?.ShowReblogs ?? false, diff --git a/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs b/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs new file mode 100644 index 0000000..e6abd4c --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Relationships; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + public class ReportsController : MastodonController + { + readonly IReportService _reports; + readonly MastodonMapper _mapper; + + public ReportsController(IReportService reports, MastodonMapper mapper) + { + _reports = reports; + _mapper = mapper; + } + + [HttpPost("/api/v1/reports"), Scope("write:reports")] + public async Task Create(CancellationToken token) + { + var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"), + Params.Bool("forward") == true, token); + if (report == default) + return NotFoundError(); + return Json(new + { + id = report.ID, + action_taken = false, + action_taken_at = default(string), + category = report.Category, + comment = report.Comment ?? string.Empty, + forwarded = report.Forwarded, + created_at = MastodonJson.Time(report.CreatedAt), + status_ids = report.PostIds, + rule_ids = Array.Empty(), + target_account = await _mapper.Account(report.TargetAccountId, token) + }); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 0485e13..cf319d4 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -112,18 +112,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [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()); diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index e7aa599..7d98fc1 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -18,6 +18,7 @@ namespace PrivaPub.Api.Mastodon.Controllers public class StatusesController : MastodonController { const int MaxContext = 200; + public const int MaxPins = 5; readonly IStatusService _statuses; readonly MastodonMapper _mapper; @@ -227,10 +228,41 @@ namespace PrivaPub.Api.Mastodon.Controllers } [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] - public Task Bookmark(string id, CancellationToken token) => Unchanged(id, token); + public async Task Bookmark(string id, CancellationToken token) + { + var post = await Visible(id, token); + if (post == default) + return NotFoundError(); + try + { + await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token); + } + catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) + { + } + return Json(await _mapper.Status(post, MyId, token)); + } [HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")] - public Task Unbookmark(string id, CancellationToken token) => Unchanged(id, token); + public async Task Unbookmark(string id, CancellationToken token) + { + await DB.Default.DeleteAsync(b => b.AvatarId == MyId && b.PostId == id); + return await Unchanged(id, token); + } + + [HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")] + public async Task Bookmarks(CancellationToken token) + { + var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find().Match(b => b.AvatarId == MyId), b => b.ID, token); + var ids = bookmarks.Select(b => b.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 postId in ids.Where(posts.ContainsKey)) + if (await VisibilityPolicy.CanSee(posts[postId], MyId, token)) + visible.Add(posts[postId]); + Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID); + return Json(await _mapper.Statuses(visible, MyId, token)); + } [HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")] public Task Mute(string id, CancellationToken token) => Unchanged(id, token); @@ -239,10 +271,32 @@ namespace PrivaPub.Api.Mastodon.Controllers 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"); + public async Task Pin(string id, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) + .ExecuteFirstAsync(token); + if (post == default) + return NotFoundError(); + if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) + return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned"); + if (await DB.Default.CountAsync(p => p.AvatarId == MyId, token) >= MaxPins) + return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts"); + try + { + await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token); + } + catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey) + { + } + return Json(await _mapper.Status(post, MyId, token)); + } [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")] - public Task Unpin(string id, CancellationToken token) => Unchanged(id, token); + public async Task Unpin(string id, CancellationToken token) + { + await DB.Default.DeleteAsync(p => p.AvatarId == MyId && p.PostId == id); + return await Unchanged(id, token); + } async Task Toggle(Task action, string id, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 6d2fd25..59591fc 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -147,6 +147,13 @@ namespace PrivaPub.Api.Mastodon.Mappers var favourited = viewerId == default ? new HashSet() : (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet(); + var bookmarked = viewerId == default + ? new HashSet() + : (await DB.Default.Find().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet(); + var pinned = (await DB.Default.Find().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet(); + var hidden = viewerId == default + ? new HashSet() + : await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token); var reblogged = viewerId == default ? new HashSet() : (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) @@ -172,6 +179,8 @@ namespace PrivaPub.Api.Mastodon.Mappers FavouritesCount = post.FavouritesCount, Favourited = favourited.Contains(post.ID), Reblogged = reblogged.Contains(post.ID), + Bookmarked = bookmarked.Contains(post.ID), + Pinned = pinned.Contains(post.ID), Sensitive = post.HasContentWarning, SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), Visibility = Visibility(post.Visibility), @@ -186,6 +195,9 @@ namespace PrivaPub.Api.Mastodon.Mappers var mapped = new List(); foreach (var post in posts) { + if (hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal) + && hidden.Contains(hiddenOriginal.ActorURI)) + continue; var status = Map(post); if (status == default) continue; diff --git a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs index f06575f..7898e18 100644 --- a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs +++ b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs @@ -1,10 +1,47 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Entities; + +using PrivaPub.ClientModels; +using PrivaPub.Extensions; +using PrivaPub.Models.Social; namespace PrivaPub.Controllers.ClientToServer { [ApiController, - Route("clientapi/moderator")] + Route("clientapi/moderator"), + Authorize(Policy = Policies.IsModerator)] public class ModeratorController : ControllerBase { + [HttpGet, Route("/clientapi/moderator/reports")] + public async Task Reports([FromQuery] bool resolved, CancellationToken token) => + Ok((await DB.Default.Find().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token)) + .Select(r => new + { + r.ID, + Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI, + r.TargetAccountId, + r.TargetActorURI, + r.PostIds, + r.ObjectURIs, + r.Category, + r.Comment, + r.Forwarded, + r.IsResolved, + r.CreatedAt, + r.ResolvedAt + })); + + [HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")] + public async Task Resolve(string id, CancellationToken token) + { + var result = await DB.Default.Update().MatchID(id) + .Modify(r => r.IsResolved, true) + .Modify(r => r.ResolvedAt, DateTime.UtcNow) + .Modify(r => r.ResolvedBy, User.GetUserId()) + .ExecuteAsync(token); + return result.MatchedCount == 0 ? NotFound() : Ok(); + } } } diff --git a/PrivaPub/Domain/Relationships/RelationshipService.cs b/PrivaPub/Domain/Relationships/RelationshipService.cs new file mode 100644 index 0000000..5d531ee --- /dev/null +++ b/PrivaPub/Domain/Relationships/RelationshipService.cs @@ -0,0 +1,169 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Social; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Domain.Relationships +{ + public interface IRelationshipService + { + Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token); + Task Unblock(LocalActor me, string targetActorUri, CancellationToken token); + Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token); + Task Unmute(LocalActor me, string targetActorUri, CancellationToken token); + Task BlockDomain(LocalActor me, string domain, CancellationToken token); + Task UnblockDomain(LocalActor me, string domain, CancellationToken token); + } + + public class RelationshipService : IRelationshipService + { + readonly DbEntities _dbEntities; + readonly IFollowService _follows; + readonly IDeliveryService _delivery; + + public RelationshipService(DbEntities dbEntities, IFollowService follows, IDeliveryService delivery) + { + _dbEntities = dbEntities; + _follows = follows; + _delivery = delivery; + } + + public async Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token) + { + try + { + await DB.Default.SaveAsync(new Block { AvatarId = me.Id, TargetAccountId = targetAccountId, TargetActorURI = targetActorUri }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + } + + await _follows.UnfollowAs(me, targetActorUri, token); + var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token); + if (follower != default) + { + await DB.Default.DeleteAsync(follower.ID); + if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI)) + { + var reject = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"), + ["type"] = "Reject", + ["actor"] = me.Uri, + ["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri } + }; + await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token); + } + else + await DB.Default.DeleteAsync(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId); + } + await Forget(me, targetAccountId, token); + } + + public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token) => + await DB.Default.DeleteAsync(b => b.AvatarId == me.Id && b.TargetActorURI == targetActorUri); + + public async Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token) + { + await DB.Default.Update() + .Match(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri) + .Modify(m => m.TargetAccountId, targetAccountId) + .Modify(m => m.HideNotifications, hideNotifications) + .Modify(m => m.ExpiresAt, duration.HasValue && duration > TimeSpan.Zero ? DateTime.UtcNow + duration : null) + .Modify(b => b.SetOnInsert(m => m.CreatedAt, DateTime.UtcNow)) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); + await Forget(me, targetAccountId, token); + } + + public async Task Unmute(LocalActor me, string targetActorUri, CancellationToken token) => + await DB.Default.DeleteAsync(m => m.AvatarId == me.Id && m.TargetActorURI == targetActorUri); + + public async Task BlockDomain(LocalActor me, string domain, CancellationToken token) + { + domain = domain?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(domain) || Uri.CheckHostName(domain) != UriHostNameType.Dns) + return; + try + { + await DB.Default.SaveAsync(new AccountDomainBlock { AvatarId = me.Id, Domain = domain }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + } + foreach (var following in await _dbEntities.Followings.Match(f => f.AvatarId == me.Id && !f.TargetIsLocal).ExecuteAsync(token)) + if (Hidden.HostMatches(following.TargetActorURI, domain)) + await _follows.UnfollowAs(me, following.TargetActorURI, token); + foreach (var follower in await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id).ExecuteAsync(token)) + if (Hidden.HostMatches(follower.ActorURI, domain)) + await DB.Default.DeleteAsync(follower.ID); + } + + public async Task UnblockDomain(LocalActor me, string domain, CancellationToken token) + { + domain = domain?.Trim().ToLowerInvariant(); + await DB.Default.DeleteAsync(b => b.AvatarId == me.Id && b.Domain == domain); + } + + static async Task Forget(LocalActor me, string targetAccountId, CancellationToken token) + { + if (string.IsNullOrEmpty(targetAccountId)) + return; + await DB.Default.DeleteAsync(e => e.AvatarId == me.Id && e.AuthorAccountId == targetAccountId); + await DB.Default.DeleteAsync(n => n.AvatarId == me.Id && n.FromAccountId == targetAccountId); + } + } + + public static class Hidden + { + public static bool HostMatches(string actorUri, string domain) => + Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) + && (uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) || uri.Host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)); + + public static async Task> AuthorsHiddenFrom(string avatarId, IEnumerable actorUris, bool forNotifications, CancellationToken token) + { + var uris = actorUris.Where(u => !string.IsNullOrEmpty(u)).Distinct().ToList(); + var hidden = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrEmpty(avatarId) || uris.Count == 0) + return hidden; + var now = DateTime.UtcNow; + foreach (var block in await DB.Default.Find().Match(b => b.AvatarId == avatarId && uris.Contains(b.TargetActorURI)).ExecuteAsync(token)) + hidden.Add(block.TargetActorURI); + foreach (var mute in await DB.Default.Find().Match(m => m.AvatarId == avatarId && uris.Contains(m.TargetActorURI)).ExecuteAsync(token)) + if ((mute.ExpiresAt == null || mute.ExpiresAt > now) && (!forNotifications || mute.HideNotifications)) + hidden.Add(mute.TargetActorURI); + var domains = await DB.Default.Find().Match(b => b.AvatarId == avatarId).ExecuteAsync(token); + foreach (var uri in uris) + if (domains.Any(d => HostMatches(uri, d.Domain))) + hidden.Add(uri); + return hidden; + } + + public static async Task> RecipientsHiding(IEnumerable avatarIds, string actorUri, CancellationToken token) + { + var ids = avatarIds.Distinct().ToList(); + var hiding = new HashSet(StringComparer.Ordinal); + if (ids.Count == 0 || string.IsNullOrEmpty(actorUri)) + return hiding; + var now = DateTime.UtcNow; + foreach (var block in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId) && b.TargetActorURI == actorUri).ExecuteAsync(token)) + hiding.Add(block.AvatarId); + foreach (var mute in await DB.Default.Find().Match(m => ids.Contains(m.AvatarId) && m.TargetActorURI == actorUri).ExecuteAsync(token)) + if (mute.ExpiresAt == null || mute.ExpiresAt > now) + hiding.Add(mute.AvatarId); + foreach (var block in await DB.Default.Find().Match(b => ids.Contains(b.AvatarId)).ExecuteAsync(token)) + if (HostMatches(actorUri, block.Domain)) + hiding.Add(block.AvatarId); + return hiding; + } + } +} diff --git a/PrivaPub/Domain/Relationships/ReportService.cs b/PrivaPub/Domain/Relationships/ReportService.cs new file mode 100644 index 0000000..4bd349d --- /dev/null +++ b/PrivaPub/Domain/Relationships/ReportService.cs @@ -0,0 +1,75 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Rendering; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Domain.Relationships +{ + public interface IReportService + { + Task File(LocalActor reporter, string targetAccountId, IReadOnlyList postIds, string comment, string category, bool forward, + CancellationToken token); + } + + public class ReportService : IReportService + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + readonly IDeliveryService _delivery; + + public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery) + { + _dbEntities = dbEntities; + _localActors = localActors; + _delivery = delivery; + } + + public async Task File(LocalActor reporter, string targetAccountId, IReadOnlyList postIds, string comment, string category, + bool forward, CancellationToken token) + { + var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token); + var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default; + if (localTarget == default && remoteTarget == default) + return default; + var targetUri = localTarget != default ? _localActors.FromAvatar(localTarget).Uri : remoteTarget.ActorURI; + var posts = postIds.Count == 0 + ? new List() + : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && p.ActorURI == targetUri).ExecuteAsync(token); + + var report = new Report + { + ReporterAvatarId = reporter.Id, + TargetAccountId = targetAccountId, + TargetActorURI = targetUri, + PostIds = posts.Select(p => p.ID).ToList(), + ObjectURIs = posts.Select(p => p.ObjectURI).ToList(), + Comment = comment?.Trim(), + Category = category is "spam" or "legal" or "violation" ? category : "other", + Forwarded = forward && remoteTarget != default + }; + await DB.Default.SaveAsync(report, token); + + if (report.Forwarded) + { + var instance = await _localActors.GetInstanceActor(token); + var flag = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = instance.ActivityUri($"flag-{report.ID}"), + ["type"] = "Flag", + ["actor"] = instance.Uri, + ["content"] = report.Comment ?? string.Empty, + ["object"] = new JsonArray(report.ObjectURIs.Prepend(targetUri).Select(u => (JsonNode)u).ToArray()) + }; + await _delivery.Enqueue(instance, new[] { remoteTarget.SharedInboxURL ?? remoteTarget.InboxURL }, flag, token); + } + return report; + } + } +} diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs index cd862b4..2e811ee 100644 --- a/PrivaPub/Domain/Social/Notifications.cs +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -12,6 +12,8 @@ namespace PrivaPub.Domain.Social { if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId) return; + if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0) + return; try { await DB.Default.SaveAsync(new Notification diff --git a/PrivaPub/Domain/Timelines/Fanout.cs b/PrivaPub/Domain/Timelines/Fanout.cs index db0676b..ecc077c 100644 --- a/PrivaPub/Domain/Timelines/Fanout.cs +++ b/PrivaPub/Domain/Timelines/Fanout.cs @@ -53,6 +53,7 @@ namespace PrivaPub.Domain.Timelines break; } + recipients.ExceptWith(await Relationships.Hidden.RecipientsHiding(recipients, post.ActorURI, token)); foreach (var avatarId in recipients) { try diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 0e6e0d1..64f609e 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -42,6 +42,8 @@ namespace PrivaPub.Federation.Actors public string Outbox => $"{Uri}/anus"; public string Followers => $"{Uri}/groupies"; public string Following => $"{Uri}/stalking"; + public string Featured => $"{Uri}/trophies"; + public string FeaturedTags => $"{Uri}/tattoos"; public string SharedInbox => $"{BaseAddress}/human-centipede"; public string Domain => new Uri(BaseAddress).Authority; public string Handle => $"{UserName}@{Domain}"; diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index f5f646a..0da8fe9 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -5,6 +5,7 @@ using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; +using PrivaPub.Models.Social; using PrivaPub.StaticServices; using System.Text.Json.Nodes; @@ -112,6 +113,34 @@ namespace PrivaPub.Federation.Controllers return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default)); } + [HttpGet, Route("{actor}/trophies")] + public async Task Featured(string actor, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + if (local is not { IsFederated: true }) + return NotFound(); + var pinIds = (await DB.Default.Find().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)) + .Select(p => p.PostId).ToList(); + var posts = pinIds.Count == 0 + ? new List() + : await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID) && p.GroupUserId == local.Id && p.ReblogOfPostId == null) + .Match(VisibilityPolicy.IsPublic).ExecuteAsync(token); + var notes = new List(); + foreach (var id in pinIds) + if (posts.FirstOrDefault(p => p.ID == id) is { } post) + notes.Add(ActivityPubRenderer.Note(post, local, default, post.InReplyToURI)); + return Activity(ActivityPubRenderer.OrderedCollection(local.Featured, notes.Count, notes)); + } + + [HttpGet, Route("{actor}/tattoos")] + public async Task FeaturedTags(string actor, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + return local is not { IsFederated: true } + ? NotFound() + : Activity(ActivityPubRenderer.OrderedCollection(local.FeaturedTags, 0, Enumerable.Empty())); + } + [HttpGet, Route("{actor}/scribbles/{postId}")] public async Task Post(string actor, string postId, CancellationToken token) { diff --git a/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs new file mode 100644 index 0000000..acf35cf --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs @@ -0,0 +1,66 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.StaticServices; + +using System.Text.Json.Nodes; + +using static PrivaPub.Federation.Objects.ActivityJson; + +namespace PrivaPub.Federation.Inbox.Handlers +{ + public class FlagHandler : IActivityHandler + { + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; + + public FlagHandler(DbEntities dbEntities, ILocalActorService localActors) + { + _dbEntities = dbEntities; + _localActors = localActors; + } + + public string Type => "Flag"; + + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) + { + var objects = activity["object"] switch + { + JsonArray array => array.Select(Id).Where(i => i != default).ToList(), + JsonNode single when Id(single) is { } id => new List { id }, + _ => new List() + }; + + LocalActor target = default; + var postUris = new List(); + foreach (var uri in objects.Take(50)) + { + var local = await _localActors.FindByUri(uri, token); + if (local is { Kind: LocalActorKind.Person } && uri.TrimEnd('/') == local.Uri) + target ??= local; + else + postUris.Add(uri); + } + var posts = postUris.Count == 0 + ? new List() + : await _dbEntities.Posts.Match(p => postUris.Contains(p.ObjectURI) && !p.IsFederatedCopy).ExecuteAsync(token); + if (target == default && posts.Count > 0) + target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token); + if (target == default) + return; + + await DB.Default.SaveAsync(new Report + { + ReporterActorURI = actor.ActorURI, + TargetAccountId = target.Id, + TargetActorURI = target.Uri, + PostIds = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ID).ToList(), + ObjectURIs = posts.Where(p => p.GroupUserId == target.Id).Select(p => p.ObjectURI).ToList(), + Comment = Value(activity, "content") + }, token); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index 150a67e..4eec123 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -46,6 +46,20 @@ namespace PrivaPub.Federation.Inbox.Handlers var target = await _localActors.FindByUri(Id(follow["object"]), token); if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) return; + if (target.Kind == LocalActorKind.Person + && await DB.Default.Find().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) + { + var reject = new JsonObject + { + ["@context"] = ActivityPubRenderer.ActivityStreams, + ["id"] = target.ActivityUri($"reject-{Guid.NewGuid():N}"), + ["type"] = "Reject", + ["actor"] = target.Uri, + ["object"] = follow.DeepClone() + }; + await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token); + return; + } var existing = await _dbEntities.Followers .Match(f => f.LocalActorId == target.Id && f.LocalActorKind == target.Kind && f.ActorURI == follower.ActorURI) diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index e20e595..9137f0f 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -76,6 +76,8 @@ namespace PrivaPub.Federation.Rendering ["outbox"] = actor.Outbox, ["followers"] = actor.Followers, ["following"] = actor.Following, + ["featured"] = actor.Featured, + ["featuredTags"] = actor.FeaturedTags, ["published"] = Day(actor.Published), ["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers, ["discoverable"] = actor.Discoverable, diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 7a22bbb..6bdaf84 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -87,6 +87,18 @@ namespace PrivaPub.Infrastructure.Data .Key(m => m.Timeline, KeyType.Ascending) .Option(o => o.Unique = true) .CreateAsync(token); + foreach (var pair in new (Func, string)[] + { + (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "block"), + (() => DB.Default.Index().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"), + (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), + (() => 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") + }) + await pair.Item1(); + await Plain(token, b => b.TargetActorURI); + await Plain(token, m => m.TargetActorURI); + await Plain(token, r => r.IsResolved, r => r.ID); 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/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index ebea1ba..216c1e4 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -19,6 +19,7 @@ using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Domain.Content; +using PrivaPub.Domain.Relationships; using PrivaPub.Domain.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Timelines; @@ -72,6 +73,7 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -138,6 +140,8 @@ namespace PrivaPub.Middleware .AddTransient() .AddTransient() .AddTransient() + .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddSingleton() diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs new file mode 100644 index 0000000..263ee69 --- /dev/null +++ b/PrivaPub/Models/Social/Relationships.cs @@ -0,0 +1,60 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + public class Block : Entity + { + public string AvatarId { get; set; } + public string TargetAccountId { get; set; } + public string TargetActorURI { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class Mute : Entity + { + public string AvatarId { get; set; } + public string TargetAccountId { get; set; } + public string TargetActorURI { get; set; } + public bool HideNotifications { get; set; } = true; + public DateTime? ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class AccountDomainBlock : Entity + { + public string AvatarId { get; set; } + public string Domain { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class Bookmark : Entity + { + public string AvatarId { get; set; } + public string PostId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class Pin : Entity + { + public string AvatarId { get; set; } + public string PostId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public class Report : Entity + { + public string ReporterAvatarId { get; set; } + public string ReporterActorURI { get; set; } + public string TargetAccountId { get; set; } + public string TargetActorURI { get; set; } + public List PostIds { get; set; } = new(); + public List ObjectURIs { get; set; } = new(); + public string Category { get; set; } = "other"; + public string Comment { get; set; } + public bool Forwarded { get; set; } + public bool IsResolved { get; set; } + public string ResolvedBy { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ResolvedAt { get; set; } + } +}