immich/server/src/validation.ts
Freddie Floydd 6798d5df32
fix(server): require at least one field to be set when updating memory (#27842)
* add zod util to require one field is set in some schemas. appy to update memory endpoint

* add test
2026-04-17 20:18:48 +00:00

267 lines
8.5 KiB
TypeScript

import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { isIP, isIPRange } from 'validator';
import z from 'zod';
export type IsIPRangeOptions = { requireCIDR?: boolean };
function isIPOrRange(value: string, options?: IsIPRangeOptions): boolean {
const { requireCIDR = true } = options ?? {};
if (isIPRange(value)) {
return true;
}
if (!requireCIDR && isIP(value)) {
return true;
}
return false;
}
/**
* Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges.
* When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed.
*
* @example
* z.string().optional().transform(...).pipe(IsIPRange())
* @example
* z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false }))
*/
export function IsIPRange(options?: IsIPRangeOptions) {
return z
.array(z.string())
.refine((arr) => arr.every((item) => isIPOrRange(item, options)), 'Must be an ip address or ip address range');
}
/**
* Like z.object().partial(), but rejects objects where every field is undefined.
* Use for update/patch DTOs where at least one field must be provided.
*
* @example
* nonEmptyPartial({ name: z.string(), bio: z.string() }).meta({ id: 'UpdateDto' });
*/
export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
return z
.object(shape)
.partial()
.refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
message: 'At least one field must be provided',
});
}
/**
* Zod schema that validates sibling-exclusion for object schemas.
* Validation passes when the target property is missing, or when none of the sibling properties are present.
* Use with .pipe() like IsIPRange.
*
* @example
* const Schema = z.object({ a: z.string().optional(), b: z.string().optional() });
* Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b']));
*/
export function IsNotSiblingOf<
TSchema extends z.ZodObject<z.ZodRawShape>,
TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>,
>(_schema: TSchema, property: TKey, siblings: TKey[]) {
type T = z.infer<TSchema>;
const message = `${String(property)} cannot exist alongside ${siblings.join(' or ')}`;
return z.custom<T>().refine(
(data) => {
if (data[property] === undefined) {
return true;
}
return siblings.every((sibling) => data[sibling] === undefined);
},
{ message },
);
}
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
async transform(value: string, metadata: ArgumentMetadata) {
if (value == 'me') {
return value;
}
return super.transform(value, metadata);
}
}
@Injectable()
export class FileNotEmptyValidator extends FileValidator {
constructor(private requiredFields: string[]) {
super({});
this.requiredFields = requiredFields;
}
isValid(files?: any): boolean {
if (!files) {
return false;
}
return this.requiredFields.every((field) => files[field]);
}
buildErrorMessage(): string {
return `Field(s) ${this.requiredFields.join(', ')} should not be empty`;
}
}
const UUIDParamSchema = z.object({
id: z.uuidv4(),
});
export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}
const UUIDAssetIDParamSchema = z.object({
id: z.uuidv4(),
assetId: z.uuidv4(),
});
export class UUIDAssetIDParamDto extends createZodDto(UUIDAssetIDParamSchema) {}
const FilenameParamSchema = z.object({
filename: z.string().regex(/^[a-zA-Z0-9_\-.]+$/, {
error: 'Filename contains invalid characters',
}),
});
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
return Number.isInteger(value) && value >= min && value <= max;
};
/**
* Unified email validation
* Converts email strings to lowercase and validates against HTML5 email regex
* @docs https://zod.dev/api?id=email
*/
export const toEmail = z
.email({
pattern: z.regexes.html5Email,
error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`,
})
.transform((val) => val.toLowerCase());
/**
* Parse ISO 8601 datetime strings to Date objects
* @docs https://zod.dev/api?id=codec
*/
export const isoDatetimeToDate = z
.codec(
z.iso.datetime({
error: (iss) => `Invalid input: expected ISO 8601 datetime string, received ${typeof iss.input}`,
}),
z.date(),
{
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
},
)
.meta({ example: '2024-01-01T00:00:00.000Z' });
/**
* Parse ISO date strings to Date objects
* @docs https://zod.dev/api?id=codec
*/
export const isoDateToDate = z
.codec(
z.iso.date({
error: (iss) => `Invalid input: expected ISO date string (YYYY-MM-DD), received ${typeof iss.input}`,
}),
z.date(),
{
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString().slice(0, 10),
},
)
.meta({ example: '2024-01-01' });
export const isValidTime = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Invalid input: expected string in HH:mm format, received string');
/**
* Latitude in range [-90, 90]. Reuse for body or query params.
*
* @example
* // Regular (body): optional coordinates
* latitudeSchema.optional().describe('Latitude coordinate')
*
* @example
* // 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);
/**
* Longitude in range [-180, 180]. Reuse for body or query params.
*
* @example
* // Regular (body): optional coordinates
* longitudeSchema.optional().describe('Longitude coordinate')
*
* @example
* // 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);
/**
* Parse string to boolean
* This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string.
* We don't use z.coerce.boolean() as any truthy value is considered true
* z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity.
* @docs https://zod.dev/api?id=coercion
* @docs https://zod.dev/api?id=stringbool
*/
export const stringToBool = z
.stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' })
.meta({ type: 'boolean' });
/**
* Parse JSON strings from multipart/form-data
*/
export const JsonParsed = z.transform((val, ctx) => {
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch {
ctx.issues.push({
code: 'custom',
message: `Invalid input: expected JSON string, received ${typeof val}`,
input: val,
});
return z.NEVER;
}
}
return val;
});
/**
* Hex color validation and normalization.
* Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix).
* Normalizes output to always include the # prefix.
*
* @example
* hexColor.optional()
*/
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export const hexColor = z
.string()
.regex(hexColorRegex)
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
/**
* Transform empty strings to null. Inner schema passed to this function must accept null.
* @docs https://zod.dev/api?id=preprocess
* @example emptyStringToNull(z.string().nullable()).optional() // [encouraged] final schema is optional
* @example emptyStringToNull(z.string().nullable()) // [encouraged] same as the one above, but final schema is not optional
* @example emptyStringToNull(z.string().nullish()) // [discouraged] same as the one above, might be confusing
* @example emptyStringToNull(z.string().optional()) // fails: string schema rejects null
* @example emptyStringToNull(z.string().nullable()).nullish() // [discouraged] passes, null is duplicated. use the first example instead
*/
export const emptyStringToNull = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((val) => (val === '' ? null : val), schema);
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));