Match legacy App\Profile morph type in user_filters block and mute queries

Read and delete paths for user_filters now match both the legacy App\Profile and current App\Models\Profile morph strings, so pre-migration block/mute rows are visible and removable again. Write paths keep the canonical class. Adds a migration that deduplicates then normalizes legacy rows.
pull/7816/head
Ashley 1 week ago
parent 98514675a8
commit 6930073035

@ -239,7 +239,7 @@ class FixDuplicateProfiles extends Command
protected function checkUserFilter($id, $oid)
{
UserFilter::whereUserId($oid)->update(['user_id' => $id]);
UserFilter::whereFilterableType(Profile::class)->whereFilterableId($oid)->update(['filterable_id' => $id]);
UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])->whereFilterableId($oid)->update(['filterable_id' => $id]);
}
protected function checkUserPronoun($id, $oid)

@ -172,7 +172,7 @@ class AccountController extends Controller
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->first();
@ -308,7 +308,7 @@ class AccountController extends Controller
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->first();
@ -479,7 +479,7 @@ class AccountController extends Controller
$limit = $request->input('limit') ?? 40;
$mutes = UserFilter::whereUserId($user->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->simplePaginate($limit)
->pluck('filterable_id');
@ -512,7 +512,7 @@ class AccountController extends Controller
$blocked = UserFilter::select('filterable_id', 'filterable_type', 'filter_type', 'user_id')
->whereUserId($user->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->simplePaginate($limit)
->pluck('filterable_id');

@ -1179,7 +1179,7 @@ class ApiV1Controller extends Controller
$blocked = UserFilter::whereUserId($target->id)
->whereFilterType('block')
->whereFilterableId($user->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->exists();
if ($blocked == true) {
@ -1426,7 +1426,7 @@ class ApiV1Controller extends Controller
$blocks = UserFilter::select('filterable_id', 'filterable_type', 'filter_type', 'user_id')
->whereUserId($user->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->orderByDesc('id')
->simplePaginate($limit)
@ -1579,7 +1579,7 @@ class ApiV1Controller extends Controller
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($profile->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->first();
@ -2535,7 +2535,7 @@ class ApiV1Controller extends Controller
}
$mutes = UserFilter::whereUserId($user->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->orderByDesc('id')
->simplePaginate($limit)
@ -2653,7 +2653,7 @@ class ApiV1Controller extends Controller
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($profile->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->first();

@ -49,7 +49,7 @@ class CommentController extends Controller
}
$filtered = UserFilter::whereUserId($status->profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereIn('filter_type', ['block'])
->whereFilterableId($profile->id)
->exists();

@ -223,7 +223,7 @@ class ProfileController extends Controller
$pid = request()->user()->profile->id;
$blocks = UserFilter::whereUserId($profile->id)
->whereFilterType('block')
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->pluck('filterable_id')
->toArray();
if (in_array($pid, $blocks)) {

@ -161,7 +161,7 @@ trait PrivacySettings
DB::transaction(function () use ($fid, $pid) {
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($fid)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->firstOrFail();
$filter->delete();
@ -244,7 +244,7 @@ trait PrivacySettings
DB::transaction(function () use ($fid, $pid) {
$filter = UserFilter::whereUserId($pid)
->whereFilterableId($fid)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->firstOrFail();
$filter->delete();

@ -97,7 +97,7 @@ class CommentPipeline implements ShouldQueue
}
$filtered = UserFilter::whereUserId($target->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereIn('filter_type', ['mute', 'block'])
->whereFilterableId($actor->id)
->exists();

@ -132,7 +132,7 @@ class DeleteRemoteProfilePipeline implements ShouldQueue
// clears each muting/blocking user's Redis cache; a bulk delete would
// leave stale entries (and inflated mute/block counts) for up to the
// 90-day cache TTL. chunkById is safe while deleting.
UserFilter::whereFilterableType(Profile::class)
UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterableId($pid)
->chunkById(100, function ($filters) {
foreach ($filters as $filter) {

@ -73,7 +73,7 @@ class DeliverBlockActivity implements ShouldBeUniqueUntilProcessing, ShouldQueue
$filter = UserFilter::whereUserId($this->blockerId)
->whereFilterableId($this->blockedId)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->first(['id']);

@ -127,7 +127,7 @@ class FeedInsertPipeline implements ShouldBeUniqueUntilProcessing, ShouldQueue
$filterableIds[] = $status['reblog']['account']['id'];
}
$filters = UserFilter::whereFilterableType(Profile::class)
$filters = UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->whereIn('filterable_id', array_unique($filterableIds))
->whereIn('filter_type', ['mute', 'block'])
->pluck('user_id')

@ -154,7 +154,7 @@ class FeedInsertRemotePipeline implements ShouldBeUniqueUntilProcessing, ShouldQ
$filterableIds[] = $status['reblog']['account']['id'];
}
$filters = UserFilter::whereFilterableType(Profile::class)
$filters = UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->whereIn('filterable_id', array_unique($filterableIds))
->whereIn('filter_type', ['mute', 'block'])
->pluck('user_id')

@ -126,7 +126,7 @@ class HashtagInsertFanoutPipeline implements ShouldBeUniqueUntilProcessing, Shou
$filterableIds[] = $status['reblog']['account']['id'];
}
$filters = UserFilter::whereFilterableType(Profile::class)
$filters = UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->whereIn('filterable_id', array_unique($filterableIds))
->whereIn('filter_type', ['mute', 'block'])
->pluck('user_id')

@ -85,7 +85,7 @@ class CleanupLegacyAccountMovePipeline implements ShouldQueue
throw new Exception('Invalid move accounts');
}
UserFilter::where('filterable_type', Profile::class)
UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->where('filterable_id', $actorAccount['id'])
->update(['filterable_id' => $targetAccount['id']]);

@ -363,7 +363,7 @@ class Profile extends Model
public function mutedIds()
{
return UserFilter::whereUserId($this->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->pluck('filterable_id');
}
@ -371,7 +371,7 @@ class Profile extends Model
public function blockedIds()
{
return UserFilter::whereUserId($this->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->pluck('filterable_id');
}

@ -36,7 +36,7 @@ class UserFilter extends Model
public function mutedUserIds($profile_id)
{
return $this->whereUserId($profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('mute')
->pluck('filterable_id');
}
@ -44,7 +44,7 @@ class UserFilter extends Model
public function blockedUserIds($profile_id)
{
return $this->whereUserId($profile_id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->pluck('filterable_id');
}

@ -74,7 +74,7 @@ class UserFilterObserver
protected function filterCreate(UserFilter $userFilter, bool $federate = true)
{
if ($userFilter->filterable_type !== Profile::class) {
if (! in_array($userFilter->filterable_type, ['App\Profile', Profile::class], true)) {
return;
}
@ -105,7 +105,7 @@ class UserFilterObserver
protected function filterDelete(UserFilter $userFilter)
{
if ($userFilter->filterable_type !== Profile::class) {
if (! in_array($userFilter->filterable_type, ['App\Profile', Profile::class], true)) {
return;
}

@ -208,7 +208,7 @@ class BlockSyncService
return DB::table('user_filters as uf')
->join('profiles as blocker', 'blocker.id', '=', 'uf.user_id')
->join('profiles as blocked', 'blocked.id', '=', 'uf.filterable_id')
->where('uf.filterable_type', Profile::class)
->whereIn('uf.filterable_type', ['App\Profile', Profile::class])
->where('uf.filter_type', 'block')
->whereNull('blocker.domain')
->whereNull('blocker.deleted_at')
@ -379,7 +379,7 @@ class BlockSyncService
public static function localBlockChanged(UserFilter $filter, bool $created): void
{
try {
if ($filter->filterable_type !== Profile::class || $filter->filter_type !== 'block') {
if (! in_array($filter->filterable_type, ['App\Profile', Profile::class], true) || $filter->filter_type !== 'block') {
return;
}
@ -429,7 +429,7 @@ class BlockSyncService
return DB::table('user_filters as uf')
->join('profiles as blocker', 'blocker.id', '=', 'uf.user_id')
->join('profiles as blocked', 'blocked.id', '=', 'uf.filterable_id')
->where('uf.filterable_type', Profile::class)
->whereIn('uf.filterable_type', ['App\Profile', Profile::class])
->where('uf.filter_type', 'block')
->where('blocker.domain', strtolower($host))
->whereNotNull('blocker.remote_url')
@ -950,7 +950,7 @@ class BlockSyncService
$exists = UserFilter::whereUserId($blocker->id)
->whereFilterableId($blocked->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->exists();
@ -1001,7 +1001,7 @@ class BlockSyncService
$filter = UserFilter::whereUserId($blocker->id)
->whereFilterableId($blocked->id)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->first();
@ -1023,7 +1023,7 @@ class BlockSyncService
$filter = UserFilter::whereKey($filterId)
->whereUserId($blockerId)
->whereFilterableId($blockedId)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->first();

@ -30,12 +30,12 @@ class RelationshipService
'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
'blocking' => UserFilter::whereUserId($aid)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterableId($tid)
->whereFilterType('block')
->exists(),
'muting' => UserFilter::whereUserId($aid)
->whereFilterableType(Profile::class)
->whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterableId($tid)
->whereFilterType('mute')
->exists(),

@ -94,7 +94,7 @@ class UserFilterService
*/
public static function searchExcludedProfileIds(int $profile_id)
{
return UserFilter::whereFilterableType(Profile::class)
return UserFilter::whereIn('filterable_type', ['App\Profile', Profile::class])
->whereFilterType('block')
->whereFilterableId($profile_id)
->pluck('user_id')

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Rewrite legacy user_filters rows that still store the old morphMap
* alias (App\Profile) to the canonical model class (App\Models\Profile).
*
* Rows written before the namespace migration kept the short alias, so
* the block/mute reads that match only the modern class skipped them.
* Collisions on the filter_unique tuple (user_id, filterable_id,
* filterable_type, filter_type) are removed first so the rewrite below
* never trips the unique index, then the survivors are normalized.
*/
public function up(): void
{
if (! Schema::hasTable('user_filters')) {
return;
}
DB::table('user_filters as legacy')
->where('legacy.filterable_type', 'App\Profile')
->whereExists(function ($q) {
$q->from('user_filters as modern')
->whereColumn('modern.user_id', 'legacy.user_id')
->whereColumn('modern.filterable_id', 'legacy.filterable_id')
->whereColumn('modern.filter_type', 'legacy.filter_type')
->where('modern.filterable_type', 'App\Models\Profile');
})
->delete();
DB::table('user_filters')
->where('filterable_type', 'App\Profile')
->update(['filterable_type' => 'App\Models\Profile']);
}
/**
* Not reversible: the legacy alias is ambiguous and reverting would
* reintroduce the morph-type mismatch this migration exists to fix.
*/
public function down(): void
{
//
}
};

@ -0,0 +1,234 @@
<?php
use App\Models\Profile;
use App\Models\User;
use App\Models\UserFilter;
use App\Services\RelationshipService;
use App\Services\UserFilterService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Laravel\Passport\Passport;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Legacy morph-type block/mute coverage
|--------------------------------------------------------------------------
|
| Rows written before the model namespace move store filterable_type as the
| old alias 'App\Profile' instead of App\Models\Profile. Reads and deletes
| that match only the modern class skipped those rows, so a legacy block or
| mute reported as inactive and could never be removed. These cases cover
| the relationship reporter, the unblock/unmute delete paths, the profile
| id helpers, and the data migration that normalizes the stored value.
|
*/
const LEGACY_TYPE = 'App\Profile';
function seedFilter(int $userId, int $targetId, string $filterType, string $type): UserFilter
{
$filter = new UserFilter;
$filter->user_id = $userId;
$filter->filterable_id = $targetId;
$filter->filterable_type = $type;
$filter->filter_type = $filterType;
$filter->save();
return $filter;
}
function runMorphNormalization(): void
{
$migration = require base_path('database/migrations/2026_10_07_000001_normalize_legacy_user_filter_morph_types.php');
$migration->up();
}
describe('legacy App\Profile rows', function () {
it('reports blocking and muting true for a legacy row', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'block', LEGACY_TYPE);
seedFilter($user->profile_id, $target->profile_id, 'mute', LEGACY_TYPE);
$rel = RelationshipService::refresh($user->profile_id, $target->profile_id);
expect($rel['blocking'])->toBeTrue();
expect($rel['muting'])->toBeTrue();
});
it('unblocks and deletes a legacy block row', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'block', LEGACY_TYPE);
Passport::actingAs($user, ['write']);
$this->postJson("/api/v1/accounts/{$target->profile_id}/unblock")
->assertOk();
expect(UserFilter::where('user_id', $user->profile_id)
->where('filterable_id', $target->profile_id)
->where('filter_type', 'block')
->exists()
)->toBeFalse();
});
it('unmutes and deletes a legacy mute row', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'mute', LEGACY_TYPE);
Passport::actingAs($user, ['write']);
$this->postJson("/api/v1/accounts/{$target->profile_id}/unmute")
->assertOk();
expect(UserFilter::where('user_id', $user->profile_id)
->where('filterable_id', $target->profile_id)
->where('filter_type', 'mute')
->exists()
)->toBeFalse();
});
it('includes legacy-blocked and legacy-muted ids in the profile helpers', function () {
$user = User::factory()->create();
$user->refresh();
$blocked = User::factory()->create();
$blocked->refresh();
$muted = User::factory()->create();
$muted->refresh();
seedFilter($user->profile_id, $blocked->profile_id, 'block', LEGACY_TYPE);
seedFilter($user->profile_id, $muted->profile_id, 'mute', LEGACY_TYPE);
$profile = Profile::find($user->profile_id);
expect($profile->blockedIds()->contains($blocked->profile_id))->toBeTrue();
expect($profile->mutedIds()->contains($muted->profile_id))->toBeTrue();
});
});
describe('warm cache eviction on legacy row removal', function () {
it('evicts a legacy-blocked id from the warm block cache on unblock', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'block', LEGACY_TYPE);
expect(collect(UserFilterService::blocks($user->profile_id))->map(fn ($i) => (string) $i))
->toContain((string) $target->profile_id);
Passport::actingAs($user, ['write']);
$this->postJson("/api/v1/accounts/{$target->profile_id}/unblock")
->assertOk();
expect(collect(UserFilterService::blocks($user->profile_id))->map(fn ($i) => (string) $i))
->not->toContain((string) $target->profile_id);
expect(UserFilterService::blockCount($user->profile_id))->toBe(0);
});
it('evicts a legacy-muted id from the warm mute cache on unmute', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'mute', LEGACY_TYPE);
expect(collect(UserFilterService::mutes($user->profile_id))->map(fn ($i) => (string) $i))
->toContain((string) $target->profile_id);
Passport::actingAs($user, ['write']);
$this->postJson("/api/v1/accounts/{$target->profile_id}/unmute")
->assertOk();
expect(collect(UserFilterService::mutes($user->profile_id))->map(fn ($i) => (string) $i))
->not->toContain((string) $target->profile_id);
expect(UserFilterService::muteCount($user->profile_id))->toBe(0);
});
});
describe('modern App\Models\Profile control', function () {
it('reports and unblocks a modern row identically', function () {
$user = User::factory()->create();
$user->refresh();
$target = User::factory()->create();
$target->refresh();
seedFilter($user->profile_id, $target->profile_id, 'block', Profile::class);
$rel = RelationshipService::refresh($user->profile_id, $target->profile_id);
expect($rel['blocking'])->toBeTrue();
Passport::actingAs($user, ['write']);
$this->postJson("/api/v1/accounts/{$target->profile_id}/unblock")
->assertOk();
expect(UserFilter::where('user_id', $user->profile_id)
->where('filterable_id', $target->profile_id)
->where('filter_type', 'block')
->exists()
)->toBeFalse();
});
});
describe('legacy morph normalization migration', function () {
it('normalizes legacy rows, drops colliding duplicates, and keeps distinct pairs', function () {
$user = User::factory()->create();
$user->refresh();
$lone = User::factory()->create();
$lone->refresh();
$collide = User::factory()->create();
$collide->refresh();
$distinct = User::factory()->create();
$distinct->refresh();
// lone legacy row with no modern twin
seedFilter($user->profile_id, $lone->profile_id, 'block', LEGACY_TYPE);
// legacy row colliding with an existing modern row on the unique tuple
$legacyCollision = seedFilter($user->profile_id, $collide->profile_id, 'block', LEGACY_TYPE);
seedFilter($user->profile_id, $collide->profile_id, 'block', Profile::class);
// distinct legacy row (mute) that must survive untouched in value
seedFilter($user->profile_id, $distinct->profile_id, 'mute', LEGACY_TYPE);
runMorphNormalization();
// a second pass is a no-op
runMorphNormalization();
// no legacy alias remains
expect(UserFilter::where('filterable_type', LEGACY_TYPE)->exists())->toBeFalse();
// the colliding legacy row was removed, the modern one kept (single row)
expect(UserFilter::where('user_id', $user->profile_id)
->where('filterable_id', $collide->profile_id)
->where('filter_type', 'block')
->count()
)->toBe(1);
expect(UserFilter::whereKey($legacyCollision->id)->exists())->toBeFalse();
// the lone and distinct rows were normalized, not lost
expect(UserFilter::where('filterable_id', $lone->profile_id)
->where('filterable_type', Profile::class)
->where('filter_type', 'block')
->exists()
)->toBeTrue();
expect(UserFilter::where('filterable_id', $distinct->profile_id)
->where('filterable_type', Profile::class)
->where('filter_type', 'mute')
->exists()
)->toBeTrue();
});
});
Loading…
Cancel
Save