From 8bcf23789d68bf420ba6981ae4b59c288f009ab0 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sat, 25 Jul 2026 16:54:11 +0200 Subject: [PATCH] strict search shapes --- .../lib/model/metadata_search_dto.dart | 10 +- .../openapi/lib/model/random_search_dto.dart | 10 +- .../openapi/lib/model/smart_search_dto.dart | 10 +- open-api/immich-openapi-specs.json | 10 ++ .../src/controllers/search.controller.spec.ts | 36 ++++++++ server/src/dtos/search.dto.ts | 91 ++++++++++--------- 6 files changed, 102 insertions(+), 65 deletions(-) diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index f6dbacccb5..62c346de6e 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -42,7 +42,7 @@ class MetadataSearchDto { this.personIds = const Optional.present(const []), this.previewPath = const Optional.absent(), this.rating = const Optional.absent(), - this.size = const Optional.absent(), + this.size = const Optional.present(250), this.state = const Optional.absent(), this.tagIds = const Optional.present(const []), this.takenAfter = const Optional.absent(), @@ -274,12 +274,6 @@ class MetadataSearchDto { /// /// Minimum value: 1 /// Maximum value: 1000 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// Optional size; /// Filter by state/province name @@ -483,7 +477,7 @@ class MetadataSearchDto { (personIds.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) + (rating == null ? 0 : rating!.hashCode) + - (size == null ? 0 : size!.hashCode) + + (size.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 5fe4022b65..e1e9c5b966 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -31,7 +31,7 @@ class RandomSearchDto { this.ocr = const Optional.absent(), this.personIds = const Optional.present(const []), this.rating = const Optional.absent(), - this.size = const Optional.absent(), + this.size = const Optional.present(250), this.state = const Optional.absent(), this.tagIds = const Optional.present(const []), this.takenAfter = const Optional.absent(), @@ -162,12 +162,6 @@ class RandomSearchDto { /// /// Minimum value: 1 /// Maximum value: 1000 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// Optional size; /// Filter by state/province name @@ -339,7 +333,7 @@ class RandomSearchDto { (ocr == null ? 0 : ocr!.hashCode) + (personIds.hashCode) + (rating == null ? 0 : rating!.hashCode) + - (size == null ? 0 : size!.hashCode) + + (size.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index d1dff77c6e..b97b09b7c5 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -36,7 +36,7 @@ class SmartSearchDto { this.query = const Optional.absent(), this.queryAssetId = const Optional.absent(), this.rating = const Optional.absent(), - this.size = const Optional.absent(), + this.size = const Optional.present(100), this.state = const Optional.absent(), this.tagIds = const Optional.present(const []), this.takenAfter = const Optional.absent(), @@ -213,12 +213,6 @@ class SmartSearchDto { /// /// Minimum value: 1 /// Maximum value: 1000 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// Optional size; /// Filter by state/province name @@ -380,7 +374,7 @@ class SmartSearchDto { (query == null ? 0 : query!.hashCode) + (queryAssetId == null ? 0 : queryAssetId!.hashCode) + (rating == null ? 0 : rating!.hashCode) + - (size == null ? 0 : size!.hashCode) + + (size.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds == null ? 0 : tagIds!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c9eda8c967..e68f85bc40 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10645,6 +10645,7 @@ "schema": { "minimum": 1, "maximum": 1000, + "default": 250, "type": "integer" } }, @@ -20716,6 +20717,7 @@ "type": "object" }, "MetadataSearchDto": { + "additionalProperties": false, "properties": { "albumIds": { "deprecated": true, @@ -21308,6 +21310,7 @@ "x-immich-state": "Deprecated" }, "size": { + "default": 250, "description": "Number of results to return", "maximum": 1000, "minimum": 1, @@ -23149,6 +23152,7 @@ "type": "object" }, "RandomSearchDto": { + "additionalProperties": false, "properties": { "albumIds": { "deprecated": true, @@ -23537,6 +23541,7 @@ "x-immich-state": "Deprecated" }, "size": { + "default": 250, "description": "Number of results to return", "maximum": 1000, "minimum": 1, @@ -24125,6 +24130,7 @@ "type": "object" }, "SearchFilter": { + "additionalProperties": false, "properties": { "albumIds": { "$ref": "#/components/schemas/IdsFilter" @@ -24233,6 +24239,7 @@ "type": "object" }, "SearchFilterBranch": { + "additionalProperties": false, "properties": { "albumIds": { "$ref": "#/components/schemas/IdsFilter" @@ -25334,6 +25341,7 @@ "type": "object" }, "SmartSearchDto": { + "additionalProperties": false, "properties": { "albumIds": { "deprecated": true, @@ -25769,6 +25777,7 @@ "x-immich-state": "Deprecated" }, "size": { + "default": 100, "description": "Number of results to return", "maximum": 1000, "minimum": 1, @@ -26092,6 +26101,7 @@ "type": "object" }, "StatisticsSearchDto": { + "additionalProperties": false, "properties": { "albumIds": { "deprecated": true, diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index d8a1f319d8..3dd245929d 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -130,6 +130,42 @@ describe(SearchController.name, () => { ); }); + it('should reject an unknown field on the request body', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: {}, oderBy: { field: 'rating' } }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'Unrecognized key: "oderBy"' }])); + }); + + it('should reject an unknown key in the filter', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { previewPath: { eq: 'preview.webp' } } }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['filter'], message: 'Unrecognized key: "previewPath"' }])); + }); + + it('should reject a nested or', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { or: [{ or: [{ city: { eq: 'Oslo' } }] }] } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['filter', 'or', 0], message: 'Unrecognized key: "or"' }]), + ); + }); + + it('should reject an empty or branch', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { or: [{}] } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['filter', 'or', 0], message: 'At least one filter condition is required' }]), + ); + }); + describe('POST /search/random', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).post('/search/random'); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 6e73a6e64f..6c2fdf22ad 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -244,47 +244,56 @@ export const SearchOrderSchema = z }) .meta({ id: 'SearchOrder' }); +const searchFilterBranchShape = { + id: IdFilterSchema, + libraryId: IdFilterNullableSchema, + type: EnumFilterAssetTypeSchema, + visibility: EnumFilterAssetVisibilitySchema, + isFavorite: BoolFilterSchema, + isMotion: BoolFilterSchema, + isOffline: BoolFilterSchema, + isEncoded: BoolFilterSchema, + hasAlbums: BoolFilterSchema, + hasPeople: BoolFilterSchema, + hasTags: BoolFilterSchema, + city: StringFilterNullableSchema, + state: StringFilterNullableSchema, + country: StringFilterNullableSchema, + make: StringFilterNullableSchema, + model: StringFilterNullableSchema, + lensModel: StringFilterNullableSchema, + description: StringPatternFilterSchema, + originalFileName: StringPatternFilterSchema, + originalPath: StringPatternFilterSchema, + ocr: StringSimilarityFilterSchema, + rating: NumberFilterNullableSchema, + fileSizeInBytes: NumberFilterSchema, + takenAt: DateFilterSchema, + createdAt: DateFilterSchema, + updatedAt: DateFilterSchema, + trashedAt: DateFilterNullableSchema, + personIds: IdsFilterSchema, + tagIds: IdsFilterSchema, + albumIds: IdsFilterSchema, + checksum: StringFilterSchema, + encodedVideoPath: StringFilterSchema, +}; + const SearchFilterBranchSchema = z - .object({ - id: IdFilterSchema, - libraryId: IdFilterNullableSchema, - type: EnumFilterAssetTypeSchema, - visibility: EnumFilterAssetVisibilitySchema, - isFavorite: BoolFilterSchema, - isMotion: BoolFilterSchema, - isOffline: BoolFilterSchema, - isEncoded: BoolFilterSchema, - hasAlbums: BoolFilterSchema, - hasPeople: BoolFilterSchema, - hasTags: BoolFilterSchema, - city: StringFilterNullableSchema, - state: StringFilterNullableSchema, - country: StringFilterNullableSchema, - make: StringFilterNullableSchema, - model: StringFilterNullableSchema, - lensModel: StringFilterNullableSchema, - description: StringPatternFilterSchema, - originalFileName: StringPatternFilterSchema, - originalPath: StringPatternFilterSchema, - ocr: StringSimilarityFilterSchema, - rating: NumberFilterNullableSchema, - fileSizeInBytes: NumberFilterSchema, - takenAt: DateFilterSchema, - createdAt: DateFilterSchema, - updatedAt: DateFilterSchema, - trashedAt: DateFilterNullableSchema, - personIds: IdsFilterSchema, - tagIds: IdsFilterSchema, - albumIds: IdsFilterSchema, - checksum: StringFilterSchema, - encodedVideoPath: StringFilterSchema, - }) + .strictObject(searchFilterBranchShape) .partial() + .refine((branch) => Object.values(branch).some((value) => value !== undefined), { + message: 'At least one filter condition is required', + }) .meta({ id: 'SearchFilterBranch' }); -export const SearchFilterSchema = SearchFilterBranchSchema.extend({ - or: z.array(SearchFilterBranchSchema).min(1).optional(), -}).meta({ id: 'SearchFilter' }); +export const SearchFilterSchema = z + .strictObject(searchFilterBranchShape) + .partial() + .extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), + }) + .meta({ id: 'SearchFilter' }); export type IdFilter = z.infer; export type IdFilterNullable = z.infer; @@ -344,7 +353,7 @@ const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({ filter: filterField, }); -const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' }); +const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema.strict()).meta({ id: 'RandomSearchDto' }); const MetadataSearchSchema = withShapeExclusivity( RandomSearchBaseSchema.extend({ @@ -360,14 +369,14 @@ const MetadataSearchSchema = withShapeExclusivity( page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), orderBy: SearchOrderSchema.optional().meta(ADDED_V3_1), cursor: cursorField, - }), + }).strict(), ).meta({ id: 'MetadataSearchDto' }); const StatisticsSearchSchema = withShapeExclusivity( BaseSearchSchema.extend({ description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD), filter: filterField, - }), + }).strict(), ).meta({ id: 'StatisticsSearchDto' }); const SmartSearchSchema = withShapeExclusivity( @@ -379,7 +388,7 @@ const SmartSearchSchema = withShapeExclusivity( page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), filter: filterField, cursor: cursorField, - }), + }).strict(), ).meta({ id: 'SmartSearchDto' }); export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}