Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot] 8f734f7377 chore(deps): update github-actions 2026-06-30 16:18:39 +00:00
Daniel Dietzler deeb042a9e feat: honor album access permissions in search endpoints (#29352) 2026-06-29 22:27:22 +02:00
Daniel Dietzler b4cc406a3f fix!: search endpoints visibility can be omitted (#29385) 2026-06-29 22:00:02 +02:00
10 changed files with 135 additions and 49 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign run: flutter build ios --config-only --no-codesign
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0 uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
with: with:
ruby-version: '3.3' ruby-version: '3.3'
bundler-cache: true bundler-cache: true
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2 uses: oasdiff/oasdiff-action/breaking@ccc2442df0d99f8c419ed73e3de88641c91b3bc6 # v0.1.3
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -149,7 +149,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with: with:
draft: true draft: true
prerelease: ${{ needs.bump_version.outputs.rc }} prerelease: ${{ needs.bump_version.outputs.rc }}
+28 -33
View File
@@ -7,18 +7,17 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."visibility" = $1 "asset"."fileCreatedAt" >= $1
and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $2
and "asset_exif"."lensModel" = $3 and "asset"."ownerId" = any ($3::uuid[])
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."isFavorite" = $4
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
"asset"."fileCreatedAt" desc "asset"."fileCreatedAt" desc
limit limit
$6 $5
offset offset
$7 $6
-- SearchRepository.searchStatistics -- SearchRepository.searchStatistics
select select
@@ -27,11 +26,10 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."visibility" = $1 "asset"."fileCreatedAt" >= $1
and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $2
and "asset_exif"."lensModel" = $3 and "asset"."ownerId" = any ($3::uuid[])
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."isFavorite" = $4
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
-- SearchRepository.searchRandom -- SearchRepository.searchRandom
@@ -41,16 +39,15 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."visibility" = $1 "asset"."fileCreatedAt" >= $1
and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $2
and "asset_exif"."lensModel" = $3 and "asset"."ownerId" = any ($3::uuid[])
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."isFavorite" = $4
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
random() random()
limit limit
$6 $5
-- SearchRepository.searchLargeAssets -- SearchRepository.searchLargeAssets
select select
@@ -60,17 +57,16 @@ from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."visibility" = $1 "asset"."fileCreatedAt" >= $1
and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $2
and "asset_exif"."lensModel" = $3 and "asset"."ownerId" = any ($3::uuid[])
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."isFavorite" = $4
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $6 and "asset_exif"."fileSizeInByte" > $5
order by order by
"asset_exif"."fileSizeInByte" desc "asset_exif"."fileSizeInByte" desc
limit limit
$7 $6
-- SearchRepository.searchSmart -- SearchRepository.searchSmart
begin begin
@@ -83,18 +79,17 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId" inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where where
"asset"."visibility" = $1 "asset"."fileCreatedAt" >= $1
and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $2
and "asset_exif"."lensModel" = $3 and "asset"."ownerId" = any ($3::uuid[])
and "asset"."ownerId" = any ($4::uuid[]) and "asset"."isFavorite" = $4
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null and "asset"."deletedAt" is null
order by order by
smart_search.embedding <=> $6 smart_search.embedding <=> $5
limit limit
$7 $6
offset offset
$8 $7
commit commit
-- SearchRepository.getEmbedding -- SearchRepository.getEmbedding
+4 -3
View File
@@ -117,7 +117,8 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchAlbumOptions & SearchAlbumOptions &
SearchOcrOptions; SearchOcrOptions;
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>; export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@@ -125,11 +126,11 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions & SearchEmbeddingOptions &
SearchExifOptions & SearchExifOptions &
SearchOneToOneRelationOptions & SearchOneToOneRelationOptions &
SearchStatusOptions & Omit<SearchStatusOptions, 'visibility'> &
SearchUserIdOptions & SearchUserIdOptions &
SearchPeopleOptions & SearchPeopleOptions &
SearchTagOptions & SearchTagOptions &
SearchOcrOptions; SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions; export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
+1 -1
View File
@@ -250,7 +250,7 @@ describe(SearchService.name, () => {
); );
expect(mocks.search.searchSmart).toHaveBeenCalledWith( expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 1, size: 100 }, { page: 1, size: 100 },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] }, { query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
); );
}); });
+24 -3
View File
@@ -73,14 +73,22 @@ export class SearchService extends BaseService {
checksum = Buffer.from(dto.checksum, encoding); checksum = Buffer.from(dto.checksum, encoding);
} }
let userIds: string[] | undefined;
if (dto.albumIds && dto.albumIds.length > 0) {
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
} else {
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
}
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 250; const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata( const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size }, { page, size },
{ {
...dto, ...dto,
checksum, checksum,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds, userIds,
orderDirection: dto.order ?? AssetOrder.Desc, orderDirection: dto.order ?? AssetOrder.Desc,
}, },
@@ -91,9 +99,13 @@ export class SearchService extends BaseService {
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> { async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth);
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
return await this.searchRepository.searchStatistics({ return await this.searchRepository.searchStatistics({
...dto, ...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds, userIds,
}); });
} }
@@ -114,7 +126,11 @@ export class SearchService extends BaseService {
} }
const userIds = await this.getUserIdsToSearch(auth, dto.visibility); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -155,7 +171,12 @@ export class SearchService extends BaseService {
const size = dto.size || 100; const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart( const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size }, { page, size },
{ ...dto, userIds: await userIds, embedding }, {
...dto,
userIds: await userIds,
embedding,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
},
); );
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
+5 -2
View File
@@ -373,12 +373,15 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) { export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
return kysely return kysely
.withPlugin(joinDeduplicationPlugin) .withPlugin(joinDeduplicationPlugin)
.selectFrom('asset') .selectFrom('asset')
.where('asset.visibility', '=', visibility) .$if(!!options.visibility, (qb) =>
options.visibility === 'not-locked'
? qb.where('asset.visibility', '!=', AssetVisibility.Locked)
: qb.where('asset.visibility', '=', options.visibility!),
)
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(options.tagIds === null, (qb) => .$if(options.tagIds === null, (qb) =>
@@ -1,5 +1,6 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -108,6 +109,71 @@ describe(SearchService.name, () => {
expect(response.assets.items.length).toBe(1); expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id); expect(response.assets.items[0].id).toBe(unstackedAsset.id);
}); });
describe('visibility', () => {
it('should filter out locked assets in a default session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(0);
});
it('should return locked assets in an elevated session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, { withStacked: false });
expect(response.assets.items.length).toBe(1);
});
});
});
describe('albumIds option', () => {
it('should return assets from shared album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
expect(response.assets.items.length).toBe(1);
});
it('should not return assets for album, a user is not in, when partner sharing is enabled', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
await ctx.newPartner({ sharedById: otherUser.id, sharedWithId: user.id });
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
'Not found or no album.read access',
);
});
}); });
describe('getSearchSuggestions', () => { describe('getSearchSuggestions', () => {
+3 -3
View File
@@ -27,7 +27,7 @@ const authFactory = ({
user, user,
}: { }: {
apiKey?: Partial<AuthApiKey>; apiKey?: Partial<AuthApiKey>;
session?: { id: string }; session?: { id?: string; hasElevatedPermission?: boolean };
user?: Omit< user?: Omit<
Partial<UserAdmin>, Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt' 'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
@@ -46,8 +46,8 @@ const authFactory = ({
if (session) { if (session) {
auth.session = { auth.session = {
id: session.id, id: session.id ?? newUuid(),
hasElevatedPermission: false, hasElevatedPermission: session.hasElevatedPermission ?? false,
}; };
} }