From 917a13d4a7cffe41dfebede849a26d4d7d64ca30 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 22:07:43 +0930 Subject: [PATCH] Filter null-account statuses from non-cached network timeline --- app/Http/Controllers/PublicApiController.php | 32 ++++++--- .../Api/NetworkTimelineNullAccountTest.php | 71 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Api/NetworkTimelineNullAccountTest.php diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 769d89726..56b0fca21 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -553,12 +553,20 @@ class PublicApiController extends Controller ->get() ->map(function ($s) use ($user) { $status = StatusService::get($s->id); - $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); - $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id); - $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id); + if ($status && isset($status['account']) && $user) { + $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); + $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id); + $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id); + } return $status; - }); + }) + ->filter(function ($s) { + // Drop statuses whose account failed to resolve (e.g. a + // deleted remote profile) so we never return account=null. + return $s && isset($s['account']); + }) + ->values(); $res = $timeline->toArray(); } else { $timeline = Status::select( @@ -582,12 +590,20 @@ class PublicApiController extends Controller ->get() ->map(function ($s) use ($user) { $status = StatusService::get($s->id); - $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); - $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id); - $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id); + if ($status && isset($status['account']) && $user) { + $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); + $status['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $s->id); + $status['reblogged'] = (bool) ReblogService::get($user->profile_id, $s->id); + } return $status; - }); + }) + ->filter(function ($s) { + // Drop statuses whose account failed to resolve (e.g. a + // deleted remote profile) so we never return account=null. + return $s && isset($s['account']); + }) + ->values(); $res = $timeline->toArray(); } } else { diff --git a/tests/Feature/Api/NetworkTimelineNullAccountTest.php b/tests/Feature/Api/NetworkTimelineNullAccountTest.php new file mode 100644 index 000000000..e006b93df --- /dev/null +++ b/tests/Feature/Api/NetworkTimelineNullAccountTest.php @@ -0,0 +1,71 @@ + true]); + config(['instance.timeline.network.cached' => false]); +}); + +function remotePublicStatus(Profile $author): Status +{ + return Status::factory()->create([ + 'profile_id' => $author->id, + 'type' => 'photo', + 'scope' => 'public', + 'visibility' => 'public', + 'local' => false, + 'uri' => 'https://remote.example/p/'.uniqid(), + ]); +} + +it('excludes statuses whose account failed to resolve', function () { + $viewer = User::factory()->create(); + $viewer->refresh(); + + $liveAuthor = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']); + $deletedAuthor = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']); + + $liveStatus = remotePublicStatus($liveAuthor); + $deletedStatus = remotePublicStatus($deletedAuthor); + + // Mark the second author deleted so AccountService::get() returns null. + $deletedAuthor->status = 'delete'; + $deletedAuthor->save(); + AccountService::del($deletedAuthor->id); + + $res = $this->actingAs($viewer) + ->getJson('/api/pixelfed/v1/timelines/network?limit=30') + ->assertOk() + ->json(); + + $ids = collect($res)->pluck('id')->map(fn ($id) => (string) $id)->all(); + + // No entry has a null account. + foreach ($res as $entry) { + expect($entry['account'] ?? null)->not->toBeNull(); + } + + // The deleted-author status is filtered out; the live one remains. + expect($ids)->not->toContain((string) $deletedStatus->id); + expect($ids)->toContain((string) $liveStatus->id); +});