diff --git a/PrivaPub.Tests/Http/MastodonStatusesTests.cs b/PrivaPub.Tests/Http/MastodonStatusesTests.cs index 749fc88..85b822b 100644 --- a/PrivaPub.Tests/Http/MastodonStatusesTests.cs +++ b/PrivaPub.Tests/Http/MastodonStatusesTests.cs @@ -130,6 +130,39 @@ namespace PrivaPub.Tests.Http Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count")); } + // Akkoma marks a post with sensitive media "sensitive" and leaves its summary empty: the media is hidden, the words are + // not, and no warning is made up for them (found by decePub's e2e tests on the town) + [Fact] + public async Task A_remote_post_sensitive_without_a_summary_hides_its_media_and_not_its_words() + { + var alice = await _host.Mastodon("alice"); + var bob = new RemoteActor(_peer, "bob"); + _peer.WebFinger(bob); + var post = await _host.PublicPostFrom(bob, alice, shape: note => { note["sensitive"] = true; note["summary"] = ""; }); + + var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body; + + Assert.True(status["sensitive"]!.GetValue()); + Assert.Equal("", status.Text("spoiler_text")); + } + + // a status asked for by id is shown even when the viewer blocked or muted its author, as Mastodon shows it: only lists + // leave such posts out (it once answered 200 with null, and decePub's thread page showed nothing) + [Fact] + public async Task A_status_of_an_author_the_viewer_blocked_is_still_shown_by_its_id() + { + var alice = await _host.Mastodon("alice"); + var bob = new RemoteActor(_peer, "bob"); + _peer.WebFinger(bob); + var post = await _host.PublicPostFrom(bob, alice); + var bobId = (await _host.Known(bob)).ID; + (await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok(); + + var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body; + + Assert.Equal(post.ID, status.Text("id")); + } + [Fact] public async Task A_poll_is_created_validated_and_federated_as_a_question() { diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index a026bff..708b0d0 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -55,7 +55,8 @@ namespace PrivaPub.Api.Mastodon.Controllers 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 (await _mapper.Status(post, MyId, token) is { } status) + results.Statuses.Add(status); if (results.Statuses.Count == 0 && type is null or "accounts") { var local = await _localActors.FindByUri(q, token); diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 017c91d..1d737d1 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -92,7 +92,8 @@ namespace PrivaPub.Api.Mastodon.Controllers 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)); + var status = post == default ? default : await _mapper.Status(post, MyId, token); + return status == default ? NotFoundError() : Json(status); } [HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")] @@ -364,7 +365,8 @@ namespace PrivaPub.Api.Mastodon.Controllers async Task Unchanged(string id, CancellationToken token) { var post = await Visible(id, token); - return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token)); + var status = post == default ? default : await _mapper.Status(post, MyId, token); + return status == default ? NotFoundError() : Json(status); } async Task Visible(string id, CancellationToken token) diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 0496f57..6d9a074 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -146,12 +146,15 @@ namespace PrivaPub.Api.Mastodon.Mappers } public async Task Status(PostEntity post, string viewerId, CancellationToken token) => - (await Statuses(new[] { post }, viewerId, token)).FirstOrDefault(); + (await Statuses(new[] { post }, viewerId, nested: false, token, hideFromViewer: false)).FirstOrDefault(); public Task> Statuses(IReadOnlyCollection posts, string viewerId, CancellationToken token) => Statuses(posts, viewerId, nested: false, token); - async Task> Statuses(IReadOnlyCollection posts, string viewerId, bool nested, CancellationToken token) + // Lists leave out what the viewer blocked or muted; a status asked for by its id is still shown, as Mastodon shows it + // (only its author's block of the viewer hides it, and that is decided before mapping) + async Task> Statuses(IReadOnlyCollection posts, string viewerId, bool nested, CancellationToken token, + bool hideFromViewer = true) { var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList(); var originals = originalIds.Count == 0 @@ -191,7 +194,7 @@ namespace PrivaPub.Api.Mastodon.Mappers ? new Dictionary>() : (await DB.Default.Find().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token)) .GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList()); - var hidden = viewerId == default + var hidden = viewerId == default || !hideFromViewer ? new HashSet() : await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token); var reblogged = viewerId == default @@ -222,7 +225,9 @@ namespace PrivaPub.Api.Mastodon.Mappers Bookmarked = bookmarked.Contains(post.ID), Pinned = pinned.Contains(post.ID), Sensitive = post.HasContentWarning, - SpoilerText = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), + //a remote post keeps its own summary: sensitive with none (Akkoma's sensitive media, Lemmy's NSFW) hides the + //media, never the words behind an invented warning; a local post warned without words gets the default one + SpoilerText = post.SpoilerText ?? (post.HasContentWarning && !post.IsFederatedCopy ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty), Visibility = Visibility(post.Visibility), Language = post.Language, MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),