diff --git a/app/Http/Controllers/Settings/PrivacySettings.php b/app/Http/Controllers/Settings/PrivacySettings.php index cdd341276..084dcb551 100644 --- a/app/Http/Controllers/Settings/PrivacySettings.php +++ b/app/Http/Controllers/Settings/PrivacySettings.php @@ -10,6 +10,7 @@ use App\Models\QuoteAuthorization; use App\Models\UserFilter; use App\Services\AccountService; use App\Services\FeaturedCollectionService; +use App\Services\FollowerService; use App\Services\QuoteService; use App\Services\RelationshipService; use Illuminate\Http\Request; @@ -295,31 +296,43 @@ trait PrivacySettings $settings = $request->user()->settings; if ($mode !== 'keep-all') { + $query = Follower::whereFollowingId($profile->id); + switch ($mode) { case 'mutual-only': $following = $profile->following()->pluck('profiles.id'); - Follower::whereFollowingId($profile->id)->whereNotIn('profile_id', $following)->delete(); + $query->whereNotIn('profile_id', $following); break; case 'only-followers': $ts = now()->subMinutes($duration); - Follower::whereFollowingId($profile->id)->where('created_at', '>', $ts)->delete(); + $query->where('created_at', '>', $ts); break; case 'remove-all': - Follower::whereFollowingId($profile->id) - ->chunkById(100, function ($followers) { - foreach ($followers as $follower) { - FeedUnfollowPipeline::dispatch($follower->profile_id, $follower->following_id)->onQueue('feed'); - } - }); - Follower::whereFollowingId($profile->id)->delete(); + // no additional constraint: remove every follower break; default: - // code... + $query = null; break; } + + // Delete followers one at a time so FollowerService::remove() runs + // for each: a bulk ->delete() skips the model event / observer and + // leaves the follower in the Redis sorted sets that back + // FollowerService::follows(), which gates private-content access. + // A removed follower must lose access, not keep it for the 7-day + // cache-marker window. + if ($query) { + $query->chunkById(100, function ($followers) { + foreach ($followers as $follower) { + FollowerService::remove($follower->profile_id, $follower->following_id); + FeedUnfollowPipeline::dispatch($follower->profile_id, $follower->following_id)->onQueue('feed'); + $follower->delete(); + } + }); + } } $profile->is_private = true; // Clear directory listing when going private so the profile can't leak diff --git a/tests/Feature/PrivacyRemoveFollowersCacheTest.php b/tests/Feature/PrivacyRemoveFollowersCacheTest.php new file mode 100644 index 000000000..5661f0dc7 --- /dev/null +++ b/tests/Feature/PrivacyRemoveFollowersCacheTest.php @@ -0,0 +1,105 @@ +create(); + Follower::create([ + 'profile_id' => $follower->id, + 'following_id' => $target->id, + 'local_profile' => true, + ]); + // Warm the FollowerService Redis sorted sets, as a live follow would. + FollowerService::add($follower->id, $target->id); + + return $follower; +} + +function goPrivate(User $user, string $mode): void +{ + test()->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->post(route('settings.privacy.account'), ['mode' => $mode, 'duration' => 60]) + ->assertOk(); +} + +it('evicts a removed follower from FollowerService::follows on remove-all', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + $profile->update(['is_private' => false, 'is_suggestable' => true]); + + $follower = warmedFollower($profile); + + // Warm cache reports the follow relationship. + expect(FollowerService::follows($follower->id, $profile->id))->toBeTrue(); + + goPrivate($user, 'remove-all'); + + // The row is gone AND the cache no longer reports the follow. + expect(Follower::whereFollowingId($profile->id)->count())->toBe(0); + expect(FollowerService::follows($follower->id, $profile->id))->toBeFalse(); +}); + +it('evicts removed followers from the cache on mutual-only', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + $profile->update(['is_private' => false, 'is_suggestable' => true]); + + // A one-way follower (not followed back) must be removed under mutual-only. + $follower = warmedFollower($profile); + + expect(FollowerService::follows($follower->id, $profile->id))->toBeTrue(); + + goPrivate($user, 'mutual-only'); + + expect(FollowerService::follows($follower->id, $profile->id))->toBeFalse(); +}); + +it('keeps a mutual follower under mutual-only', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + $profile->update(['is_private' => false, 'is_suggestable' => true]); + + $mutual = warmedFollower($profile); + // The profile follows them back -> mutual, must be kept. + Follower::create([ + 'profile_id' => $profile->id, + 'following_id' => $mutual->id, + 'local_profile' => true, + ]); + FollowerService::add($profile->id, $mutual->id); + + goPrivate($user, 'mutual-only'); + + expect(Follower::whereProfileId($mutual->id)->whereFollowingId($profile->id)->exists())->toBeTrue(); + expect(FollowerService::follows($mutual->id, $profile->id))->toBeTrue(); +});