From 7b90bc869976366a16704910ae1edc8964db6b58 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 13 Sep 2026 21:55:18 +0930 Subject: [PATCH 1/2] Fix remove-all follower purge chunkById key and gate the route behind dangerzone --- .../Controllers/Settings/PrivacySettings.php | 4 +- routes/web.php | 2 +- .../Feature/PrivacySettingsRemoveAllTest.php | 93 +++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/PrivacySettingsRemoveAllTest.php diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index b09d81c40..ed5cf10e3 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -226,8 +226,10 @@ trait PrivacySettings break; case 'remove-all': + // No restrictive ->select() here: chunkById needs the primary + // key ('id') to paginate. profile_id/following_id still come + // back on the full model for the dispatched job. Follower::whereFollowingId($profile->id) - ->select(['profile_id', 'following_id']) ->chunkById(100, function ($followers) { foreach ($followers as $follower) { FeedUnfollowPipeline::dispatch($follower->profile_id, $follower->following_id)->onQueue('feed'); diff --git a/routes/web.php b/routes/web.php index 9060ed139..3b8a1f0b7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -309,7 +309,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->grou Route::post('privacy/blocked-instances', [SettingsController::class, 'blockedInstanceStore']); Route::post('privacy/blocked-instances/unblock', [SettingsController::class, 'blockedInstanceUnblock'])->name('settings.privacy.blocked-instances.unblock'); Route::get('privacy/blocked-keywords', [SettingsController::class, 'blockedKeywords'])->name('settings.privacy.blocked-keywords'); - Route::post('privacy/account', [SettingsController::class, 'privateAccountOptions'])->name('settings.privacy.account'); + Route::post('privacy/account', [SettingsController::class, 'privateAccountOptions'])->name('settings.privacy.account')->middleware('dangerzone'); Route::prefix('remove')->middleware('dangerzone')->group(function () { Route::get('request/temporary', [SettingsController::class, 'removeAccountTemporary'])->name('settings.remove.temporary'); Route::post('request/temporary', [SettingsController::class, 'removeAccountTemporarySubmit']); diff --git a/tests/Feature/PrivacySettingsRemoveAllTest.php b/tests/Feature/PrivacySettingsRemoveAllTest.php new file mode 100644 index 000000000..4ee693500 --- /dev/null +++ b/tests/Feature/PrivacySettingsRemoveAllTest.php @@ -0,0 +1,93 @@ +select() on the +| chunkById query previously omitted the primary key, so chunkById threw a +| RuntimeException after the first 100 rows -> 500, no deletion, no privacy +| switch. +| +*/ + +describe('remove-all', function () { + it('deletes all followers across chunks and switches the account to private', function () { + Queue::fake(); + + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + $profile->update(['is_private' => false, 'is_suggestable' => true]); + + // More than one chunk (chunkById uses 100) to exercise pagination past + // the first chunk, which is exactly where the missing-key bug threw. + $followerCount = 150; + for ($i = 0; $i < $followerCount; $i++) { + Follower::create([ + 'profile_id' => Profile::factory()->create()->id, + 'following_id' => $profile->id, + 'local_profile' => true, + ]); + } + + expect(Follower::whereFollowingId($profile->id)->count())->toBe($followerCount); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->post(route('settings.privacy.account'), [ + 'mode' => 'remove-all', + 'duration' => 60, + ]) + ->assertOk(); + + // A job per removed follower, across all chunks (not just the first 100). + Queue::assertPushed(FeedUnfollowPipeline::class, $followerCount); + + // Every follower row is gone. + expect(Follower::whereFollowingId($profile->id)->count())->toBe(0); + + // The account was switched to private and removed from the directory. + $profile->refresh(); + expect((bool) $profile->is_private)->toBeTrue() + ->and((bool) $profile->is_suggestable)->toBeFalse(); + }); + + it('requires password confirmation (dangerzone) before removing followers', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + $profile->update(['is_private' => false, 'is_suggestable' => true]); + + Follower::create([ + 'profile_id' => Profile::factory()->create()->id, + 'following_id' => $profile->id, + 'local_profile' => true, + ]); + + // No confirmed-password session: dangerzone must redirect to the confirm + // page and the destructive action must not run. + $this->actingAs($user) + ->post(route('settings.privacy.account'), [ + 'mode' => 'remove-all', + 'duration' => 60, + ]) + ->assertRedirect(route('password.confirm')); + + expect(Follower::whereFollowingId($profile->id)->count())->toBe(1); + $profile->refresh(); + expect((bool) $profile->is_private)->toBeFalse(); + }); +}); From 7a5626766edf752ed57b7c70ba79cab1d87a07b4 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 13 Sep 2026 21:58:16 +0930 Subject: [PATCH 2/2] Update PrivacySettings.php --- app/Http/Controllers/Settings/PrivacySettings.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index ed5cf10e3..bca31b746 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -226,9 +226,6 @@ trait PrivacySettings break; case 'remove-all': - // No restrictive ->select() here: chunkById needs the primary - // key ('id') to paginate. profile_id/following_id still come - // back on the full model for the dispatched job. Follower::whereFollowingId($profile->id) ->chunkById(100, function ($followers) { foreach ($followers as $follower) {