Invalidate FollowerService cache when removing followers on go-private

pull/7534/head
Your Name 1 month ago
parent 0816f662e5
commit dba24e9caf

@ -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

@ -0,0 +1,105 @@
<?php
use App\Models\Follower;
use App\Models\Profile;
use App\Models\User;
use App\Services\FollowerService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Going private must evict removed followers from the FollowerService cache
|--------------------------------------------------------------------------
|
| privateAccountOptions() bulk-deleted followers with a query-builder delete,
| which skips FollowerObserver and leaves the removed follower in the Redis
| sorted sets that back FollowerService::follows() — the gate for private
| content access. A removed follower could still pass the follows() check
| (authorization bypass) until the 7-day cache marker expired. Removal must
| update the cache so follows() returns false immediately.
|
*/
beforeEach(function () {
Queue::fake();
});
function warmedFollower(Profile $target): Profile
{
$follower = Profile::factory()->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();
});
Loading…
Cancel
Save