diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php index 3f4c93d1149..736e1102404 100644 --- a/app/Relations/HasManyPhotosByTag.php +++ b/app/Relations/HasManyPhotosByTag.php @@ -11,7 +11,9 @@ use App\Contracts\Exceptions\InternalLycheeException; use App\Enum\OrderSortingType; use App\Exceptions\Internal\NotImplementedException; +use App\Models\Builders\PhotoBuilder; use App\Models\Extensions\SortingDecorator; +use App\Models\Photo; use App\Models\TagAlbum; use App\Repositories\ConfigManager; use Illuminate\Database\Eloquent\Builder; @@ -70,10 +72,6 @@ public function addEagerConstraints(array $albums): void /** @var TagAlbum $album */ $album = $albums[0]; - $tag_ids = DB::table('tag_albums_tags')->where('album_id', '=', $album->id) - ->select('tag_id') - ->pluck('tag_id')->all(); - $tag_ids = $album->relationLoaded('tags') ? $album->tags->pluck('id')->all() : DB::table('tag_albums_tags') @@ -86,10 +84,33 @@ public function addEagerConstraints(array $albums): void $unlocked_album_ids = \App\Policies\AlbumPolicy::getUnlockedAlbumIDs(); $config_manager = app(ConfigManager::class); + + // Build a subquery that selects the IDs of photos the current user may see + // that also carry the required tags. We deliberately keep the access-control + // JOINs (photo_album, albums, computed_access_permissions) *inside* this + // subquery rather than on the outer relation query: + // + // • applySearchabilityFilter / applySensitivityFilter both add a + // LEFT JOIN photo_album that produces one row per album membership. + // A photo belonging to N regular albums therefore appears N times. + // + // • If we applied those JOINs directly to the outer query and used + // SELECT DISTINCT to collapse duplicates, PostgreSQL would require + // every ORDER BY expression to appear literally in the SELECT list. + // Raw expressions such as COALESCE(photos.rating_avg, 0) used for + // rating sorting are not in photos.* as a literal expression, so + // PostgreSQL raises an error. + // + // By using a whereIn subquery we avoid both problems: the outer relation + // query has no extra JOINs (no duplicates) and no DISTINCT (no ORDER BY + // restriction). + /** @var PhotoBuilder $ids_query */ + $ids_query = Photo::query()->select('photos.id'); + if ($config_manager->getValueAsBool('TA_override_visibility')) { $this->photo_query_policy ->applySensitivityFilter( - query: $this->getRelationQuery(), + query: $ids_query, user: $user, origin: null, include_nsfw: !$config_manager->getValueAsBool('hide_nsfw_in_tag_albums') @@ -98,7 +119,7 @@ public function addEagerConstraints(array $albums): void } else { $this->photo_query_policy ->applySearchabilityFilter( - query: $this->getRelationQuery(), + query: $ids_query, user: $user, unlocked_album_ids: $unlocked_album_ids, origin: null, @@ -106,6 +127,8 @@ public function addEagerConstraints(array $albums): void ) ->where(fn (Builder $q) => $this->getPhotoIdsWithTags($q, $tag_ids, $album->is_and)); } + + $this->getRelationQuery()->whereIn('photos.id', $ids_query); } /** diff --git a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php index 96eb9889746..44125ce175e 100644 --- a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php +++ b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php @@ -18,6 +18,7 @@ namespace Tests\Feature_v2\Album; +use App\Models\Album; use Tests\Feature_v2\Base\BaseApiWithDataTest; /** @@ -125,4 +126,30 @@ public function testGetAlbumPhotosMissingAlbumId(): void 'message' => 'The album id field is required.', ]); } + + public function testTagAlbumPhotosNoDuplicatesWhenPhotoInMultipleAlbums(): void + { + // Regression test for: tag album photo listing returns duplicate entries. + // When a photo belongs to multiple regular albums, the LEFT JOIN with + // photo_album in applySearchabilityFilter produces one row per album + // membership, causing duplicate photos in the tag album response. + + $this->actingAs($this->userMayUpload1); + + // photo1 is already in album1 and has the 'test' tag. + // tagAlbum1 is linked to the 'test' tag. + // Add photo1 to a second album to trigger the duplicate via the photo_album JOIN. + $extraAlbum = Album::factory()->as_root()->owned_by($this->userMayUpload1)->create(); + $this->photo1->albums()->attach($extraAlbum->id); + + $response = $this->getJsonWithData('Album::photos', ['album_id' => $this->tagAlbum1->id]); + $this->assertOk($response); + + $photos = $response->json('photos'); + $photoIds = array_column($photos, 'id'); + + // Each photo must appear exactly once. + $this->assertSame(count(array_unique($photoIds)), count($photoIds), 'Tag album must not return duplicate photos'); + $this->assertContains($this->photo1->id, $photoIds); + } }