diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 743abd442..2da7aaf93 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19889,12 +19889,14 @@ }, "latitude": { "description": "Latitude coordinate", + "format": "double", "maximum": 90, "minimum": -90, "type": "number" }, "longitude": { "description": "Longitude coordinate", + "format": "double", "maximum": 180, "minimum": -180, "type": "number" @@ -22131,6 +22133,7 @@ "fNumber": { "default": null, "description": "F-number (aperture)", + "format": "double", "nullable": true, "type": "number" }, @@ -22145,6 +22148,7 @@ "focalLength": { "default": null, "description": "Focal length in mm", + "format": "double", "nullable": true, "type": "number" }, @@ -22159,6 +22163,7 @@ "latitude": { "default": null, "description": "GPS latitude", + "format": "double", "nullable": true, "type": "number" }, @@ -22171,6 +22176,7 @@ "longitude": { "default": null, "description": "GPS longitude", + "format": "double", "nullable": true, "type": "number" }, @@ -24287,32 +24293,40 @@ "NumberFilter": { "properties": { "eq": { + "format": "double", "type": "number" }, "gt": { + "format": "double", "type": "number" }, "gte": { + "format": "double", "type": "number" }, "in": { "items": { + "format": "double", "type": "number" }, "minItems": 1, "type": "array" }, "lt": { + "format": "double", "type": "number" }, "lte": { + "format": "double", "type": "number" }, "ne": { + "format": "double", "type": "number" }, "notIn": { "items": { + "format": "double", "type": "number" }, "minItems": 1, @@ -24324,34 +24338,42 @@ "NumberFilterNullable": { "properties": { "eq": { + "format": "double", "nullable": true, "type": "number" }, "gt": { + "format": "double", "type": "number" }, "gte": { + "format": "double", "type": "number" }, "in": { "items": { + "format": "double", "type": "number" }, "minItems": 1, "type": "array" }, "lt": { + "format": "double", "type": "number" }, "lte": { + "format": "double", "type": "number" }, "ne": { + "format": "double", "nullable": true, "type": "number" }, "notIn": { "items": { + "format": "double", "type": "number" }, "minItems": 1, @@ -25096,10 +25118,12 @@ }, "latitude": { "description": "Latitude coordinate", + "format": "double", "type": "number" }, "longitude": { "description": "Longitude coordinate", + "format": "double", "type": "number" }, "name": { @@ -26489,7 +26513,9 @@ "properties": { "angle": { "description": "Rotation angle in degrees", - "type": "number" + "maximum": 270, + "minimum": 0, + "type": "integer" } }, "required": [ @@ -31554,6 +31580,7 @@ "latitude": { "description": "Array of latitude coordinates extracted from EXIF GPS data", "items": { + "format": "double", "nullable": true, "type": "number" }, @@ -31570,6 +31597,7 @@ "localOffsetHours": { "description": "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", "items": { + "format": "double", "type": "number" }, "type": "array" @@ -31577,6 +31605,7 @@ "longitude": { "description": "Array of longitude coordinates extracted from EXIF GPS data", "items": { + "format": "double", "nullable": true, "type": "number" }, @@ -31600,6 +31629,7 @@ "ratio": { "description": "Array of aspect ratios (width/height) for each asset", "items": { + "format": "double", "type": "number" }, "type": "array" @@ -31783,6 +31813,7 @@ }, "latitude": { "description": "Latitude coordinate", + "format": "double", "maximum": 90, "minimum": -90, "type": "number" @@ -31796,6 +31827,7 @@ }, "longitude": { "description": "Longitude coordinate", + "format": "double", "maximum": 180, "minimum": -180, "type": "number" diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index e862a117e..f60db435e 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -31,7 +31,9 @@ const CropParametersSchema = z const RotateParametersSchema = z .object({ angle: z - .number() + .int() + .min(0) + .max(270) .refine((v) => [0, 90, 180, 270].includes(v), { error: 'Angle must be one of the following values: 0, 90, 180, 270', }) diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index b918c4abd..99bdaaf10 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -18,12 +18,12 @@ export const ExifResponseSchema = z modifyDate: z.string().meta({ format: 'date-time' }).nullish().default(null).describe('Modification date/time'), timeZone: z.string().nullish().default(null).describe('Time zone'), lensModel: z.string().nullish().default(null).describe('Lens model'), - fNumber: z.number().nullish().default(null).describe('F-number (aperture)'), - focalLength: z.number().nullish().default(null).describe('Focal length in mm'), + fNumber: z.number().meta({ format: 'double' }).nullish().default(null).describe('F-number (aperture)'), + focalLength: z.number().meta({ format: 'double' }).nullish().default(null).describe('Focal length in mm'), iso: z.int().nullish().default(null).describe('ISO sensitivity'), exposureTime: z.string().nullish().default(null).describe('Exposure time'), - latitude: z.number().nullish().default(null).describe('GPS latitude'), - longitude: z.number().nullish().default(null).describe('GPS longitude'), + latitude: z.number().meta({ format: 'double' }).nullish().default(null).describe('GPS latitude'), + longitude: z.number().meta({ format: 'double' }).nullish().default(null).describe('GPS longitude'), city: z.string().nullish().default(null).describe('City name'), state: z.string().nullish().default(null).describe('State/province name'), country: z.string().nullish().default(null).describe('Country name'), diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 1f65c3e43..686ce38a2 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -94,8 +94,8 @@ const SearchPeopleSchema = z const PlacesResponseSchema = z .object({ name: z.string().describe('Place name'), - latitude: z.number().describe('Latitude coordinate'), - longitude: z.number().describe('Longitude coordinate'), + latitude: z.number().meta({ format: 'double' }).describe('Latitude coordinate'), + longitude: z.number().meta({ format: 'double' }).describe('Longitude coordinate'), admin1name: z.string().optional().describe('Administrative level 1 name (state/province)'), admin2name: z.string().optional().describe('Administrative level 2 name (county/district)'), }) @@ -174,23 +174,23 @@ const StringPatternFilterSchema = nonEmptyPartial({ }).meta({ id: 'StringPatternFilter' }); const numberRangeShape = { - lt: z.number(), - lte: z.number(), - gt: z.number(), - gte: z.number(), - in: z.array(z.number()).min(1), - notIn: z.array(z.number()).min(1), + lt: z.number().meta({ format: 'double' }), + lte: z.number().meta({ format: 'double' }), + gt: z.number().meta({ format: 'double' }), + gte: z.number().meta({ format: 'double' }), + in: z.array(z.number().meta({ format: 'double' })).min(1), + notIn: z.array(z.number().meta({ format: 'double' })).min(1), }; const NumberFilterSchema = nonEmptyPartial({ - eq: z.number(), - ne: z.number(), + eq: z.number().meta({ format: 'double' }), + ne: z.number().meta({ format: 'double' }), ...numberRangeShape, }).meta({ id: 'NumberFilter' }); const NumberFilterNullableSchema = nonEmptyPartial({ - eq: z.number().nullable(), - ne: z.number().nullable(), + eq: z.number().meta({ format: 'double' }).nullable(), + ne: z.number().meta({ format: 'double' }).nullable(), ...numberRangeShape, }).meta({ id: 'NumberFilterNullable' }); diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a2ee06c87..c85c7e83a 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -75,7 +75,9 @@ const TimeBucketAssetResponseSchema = z .object({ id: z.array(z.string()).describe('Array of asset IDs in the time bucket'), ownerId: z.array(z.string()).describe('Array of owner IDs for each asset'), - ratio: z.array(z.number()).describe('Array of aspect ratios (width/height) for each asset'), + ratio: z + .array(z.number().meta({ format: 'double' })) + .describe('Array of aspect ratios (width/height) for each asset'), isFavorite: z.array(z.boolean()).describe('Array indicating whether each asset is favorited'), visibility: z .array(AssetVisibilitySchema) @@ -90,7 +92,7 @@ const TimeBucketAssetResponseSchema = z .describe('Array of UTC timestamps when each asset was originally uploaded to Immich'), fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'), localOffsetHours: z - .array(z.number()) + .array(z.number().meta({ format: 'double' })) .describe( "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", ), @@ -110,11 +112,11 @@ const TimeBucketAssetResponseSchema = z city: z.array(z.string().nullable()).optional().describe('Array of city names extracted from EXIF GPS data'), country: z.array(z.string().nullable()).optional().describe('Array of country names extracted from EXIF GPS data'), latitude: z - .array(z.number().nullable()) + .array(z.number().meta({ format: 'double' }).nullable()) .optional() .describe('Array of latitude coordinates extracted from EXIF GPS data'), longitude: z - .array(z.number().nullable()) + .array(z.number().meta({ format: 'double' }).nullable()) .optional() .describe('Array of longitude coordinates extracted from EXIF GPS data'), }) diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 0c2b18b46..77c06c244 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -225,6 +225,8 @@ const patchOpenAPI = (document: OpenAPIObject) => { document.components.schemas = sortKeys(schemas); + const errors: string[] = []; + for (const [schemaName, schema] of Object.entries(schemas)) { if (!schema.properties) { continue; @@ -232,16 +234,35 @@ const patchOpenAPI = (document: OpenAPIObject) => { schema.properties = sortKeys(schema.properties); - for (const [key, value] of Object.entries(schema.properties)) { - if (typeof value === 'string') { + for (const [key, initialValue] of Object.entries(schema.properties)) { + if (typeof initialValue === 'string' || !isSchema(initialValue)) { continue; } - if (isSchema(value) && value.type === 'number' && value.format === 'float') { - throw new Error(`Invalid number format: ${schemaName}.${key}=float (use double instead). `); + // check array types + let value: SchemaObject | ReferenceObject = initialValue; + if (value.type === 'array' && value.items) { + value = value.items; + } + + if (isSchema(value) && value.type === 'number') { + if (value.format === 'float') { + errors.push(`Invalid number format: ${schemaName}.${key}=float (use double instead). `); + } + + // verify it was meant to be a number (and not an integer) + if (!value.format) { + errors.push( + `${schemaName}.${key} is a number (not an integer) and requires a format (e.g .meta({ format: 'double' })). `, + ); + } } } schema.required?.sort(); + + if (errors.length > 0) { + throw new Error(`Schema validation failed:\n ${errors.join('\n ')}`); + } } } diff --git a/server/src/validation.ts b/server/src/validation.ts index 467a87b67..f9f4288ee 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -185,7 +185,7 @@ export const isoDateToDate = z * // Pipe (query): coerce string to number then validate range * z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)') */ -export const latitudeSchema = z.number().min(-90).max(90); +export const latitudeSchema = z.number().meta({ format: 'double' }).min(-90).max(90); /** * Longitude in range [-180, 180]. Reuse for body or query params. @@ -198,7 +198,7 @@ export const latitudeSchema = z.number().min(-90).max(90); * // Pipe (query): coerce string to number then validate range * z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)') */ -export const longitudeSchema = z.number().min(-180).max(180); +export const longitudeSchema = z.number().meta({ format: 'double' }).min(-180).max(180); /** * Parse string to boolean