A status asked for by id is shown, and remote warnings are their own

GET /api/v1/statuses/:id applied the viewer's own blocks and mutes and
answered 200 with null for a post of an author the viewer had blocked;
decePub's thread page then showed nothing. As in Mastodon, a status asked
for by id (or acted on) is shown whatever the viewer blocked or muted,
and only lists leave such posts out; when nothing can be shown the
answer is 404, never null, and search leaves it out.

A remote post marked sensitive with an empty summary (Akkoma's sensitive
media, Lemmy's NSFW) got an invented "Content warning" that hid its
words. A remote post now keeps its own summary: sensitive without one
hides the media only. A local post warned without words still gets the
default warning.

Both found by decePub's end-to-end tests on the town.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 00:09:51 +02:00
1 parent d405269526
commit 6b55b5c35c
4 files changed
+48 -7

No files matched your search

@@ -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<bool>());
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()
{
@@ -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);
@@ -92,7 +92,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> 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<IActionResult> 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<PostEntity> Visible(string id, CancellationToken token)
@@ -146,12 +146,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
}
public async Task<Status> 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<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
Statuses(posts, viewerId, nested: false, token);
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> 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<List<Status>> Statuses(IReadOnlyCollection<PostEntity> 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<string, List<int>>()
: (await DB.Default.Find<PollVote>().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<string>()
: 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),