immich/server/src/validation.ts

241 lines
7.2 KiB
TypeScript
Raw Normal View History

import { FileValidator, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
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;
}
return !requireCIDR && isIP(value);
}
/**
* 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 of the following fields is required: ${Object.keys(shape).join(', ')}`,
});
}
/**
* 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 = `${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 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(),
});
2026-01-09 17:59:52 -05:00
export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}
const UUIDv7ParamSchema = z.object({
id: z.uuidv7(),
});
export class UUIDv7ParamDto extends createZodDto(UUIDv7ParamSchema) {}
const UUIDAssetIDParamSchema = z.object({
id: z.uuidv4(),
assetId: z.uuidv4(),
});
export class UUIDAssetIDParamDto extends createZodDto(UUIDAssetIDParamSchema) {}
feat: restore database backups (#23978) * feat: ProcessRepository#createSpawnDuplexStream * test: write tests for ProcessRepository#createSpawnDuplexStream * feat: StorageRepository#createGzip,createGunzip,createPlainReadStream * feat: backups util (args, create, restore, progress) * feat: wait on maintenance operation lock on boot * chore: use backup util from backup.service.ts test: update backup.service.ts tests with new util * feat: list/delete backups (maintenance services) * chore: open api fix: missing action in cli.service.ts * chore: add missing repositories to MaintenanceModule * refactor: move logSecret into module init * feat: initialise StorageCore in maintenance mode * feat: authenticate websocket requests in maintenance mode * test: add mock for new storage fns * feat: add MaintenanceEphemeralStateRepository refactor: cache the secret in memory * test: update service worker tests * feat: add external maintenance mode status * feat: synchronised status, restore db action * test: backup restore service tests * refactor: DRY end maintenance * feat: list and delete backup routes * feat: start action on boot * fix: should set status on restore end * refactor: add maintenanceStore to hold writables * feat: sync status to web app * feat: web impl. * test: various utils for testings * test: web e2e tests * test: e2e maintenance spec * test: update cli spec * chore: e2e lint * chore: lint fixes * chore: lint fixes * feat: start restore flow route * test: update e2e tests * chore: remove neon lights on maintenance action pages * fix: use 'startRestoreFlow' on onboarding page * chore: ignore any library folder in `docker/` * fix: load status on boot * feat: upload backups * refactor: permit any .sql(.gz) to be listed/restored * feat: download backups from list * fix: permit uploading just .sql files * feat: restore just .sql files * fix: don't show backups list if logged out * feat: system integrity check in restore flow * test: not providing failed backups in API anymore * test: util should also not try to use failedBackups * fix: actually assign inputStream * test: correct test backup prep. * fix: ensure task is defined to show error * test: fix docker cp command * test: update e2e web spec to select next button * test: update e2e api tests * test: refactor timeouts * chore: remove `showDelete` from maint. settings * chore: lint * chore: lint * fix: make sure backups are correctly sorted for clean up * test: update service spec * test: adjust e2e timeout * test: increase web timeouts for ci * chore: move gitignore changes * chore: additional filename validation * refactor: better typings for integrity API * feat: higher accuracy progress tracking * chore: delay lock retry * refactor: remove old maintenance settings * refactor: clean up tailwind classes * refactor: use while loop rather than recursive calls * test: update service specs * chore: check canParse too * chore: lint * fix: logic error causing infinite loop * refactor: use <ProgressBar /> from ui library * fix: create or overwrite file * chore: i18n pass, update progress bar * fix: wrong translation string * chore: update colour variables * test: update web test for new maint. page * chore: format, fix key * test: update tests to be more linter complaint & use new routines * chore: update onClick -> onAction, title -> breadcrumbs * fix: use wrench icon in admin settings sidebar * chore: add translation strings to accordion * chore: lint * refactor: move maintenance worker init into service * refactor: `maintenanceStatus` -> `getMaintenanceStatus` refactor: `integrityCheck` -> `detectPriorInstall` chore: add `v2.4.0` version refactor: `/backups/list` -> `/backups` refactor: use sendFile in download route refactor: use separate backups permissions chore: correct descriptions refactor: permit handler that doesn't return promise for sendfile * refactor: move status impl into service refactor: add active flag to maintenance status * refactor: split into database backup controller * test: split api e2e tests and passing * fix: move end button into authed default maint page * fix: also show in restore flow * fix: import getMaintenanceStatus * test: split web e2e tests * refactor: ensure detect install is consistently named * chore: ensure admin for detect install while out of maint. * refactor: remove state repository * test: update maint. worker service spec * test: split backup service spec * refactor: rename db backup routes * refactor: instead of param, allow bulk backup deletion * test: update sdk use in e2e test * test: correct deleteBackup call * fix: correct type for serverinstall response dto * chore: validate filename for deletion * test: wip * test: backups no longer take path param * refactor: scope util to database-backups instead of backups * fix: update worker controller with new route * chore: use new admin page actions * chore: remove stray comment * test: rename outdated test * refactor: getter pattern for maintenance secret * refactor: `createSpawnDuplexStream` -> `spawnDuplexStream` * refactor: prefer `Object.assign` * refactor: remove useless try {} block * refactor: prefer `type Props` refactor: prefer arrow function * refactor: use luxon API for minutesAgo * chore: remove change to gitignore * refactor: prefer `type Props` * refactor: remove async from onMount * refactor: use luxon toRelative for relative time * refactor: duplicate logic check * chore: open api * refactor: begin moving code into web//services * refactor: don't use template string with $t * test: use dialog role to match prompt * refactor: split actions into flow/restore * test: fix action value * refactor: move more service calls into web//services * chore: should void fn return * chore: bump 2.4.0 to 2.5.0 in controller * chore: bump 2.4.0 to 2.5.0 in controller * refactor: use events for web//services * chore: open api * chore: open api * refactor: don't await returned promise * refactor: remove redundant check * refactor: add `type: command` to actions * refactor: split backup entries into own component * refactor: split restore flow into separate components * refactor(web): split BackupDelete event * chore: stylings * chore: stylings * fix: don't log query failure on first boot * feat: support pg_dumpall backups * feat: display information about each backup * chore: i18n * feat: rollback to restore point on migrations failure * feat: health check after restore * chore: format * refactor: split health check into separate function * refactor: split health into repository test: write tests covering rollbacks * fix: omit 'health' requirement from createDbBackup * test(e2e): rollback test * fix: wrap text in backup entry * fix: don't shrink context menu button * fix: correct CREATE DB syntax for postgres * test: rename backups generated by test * feat: add filesize to backup response dto * feat: restore list * feat: ui work * fix: e2e test * fix: e2e test * pr feedback * pr feedback --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-20 15:22:28 +00:00
const FilenameParamSchema = z.object({
filename: z.string().regex(/^[a-zA-Z0-9_\-.]+$/, {
error: 'Filename contains invalid characters',
}),
});
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
/**
* 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}`,
offset: true,
}),
z.date(),
{
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
},
)
.meta({ example: '2024-01-01T00:00:00.000Z' });
2025-02-07 10:06:58 -05:00
/**
* 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) => DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'),
},
)
.meta({ example: '2024-01-01' });
/**
* 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' });
2025-07-15 13:14:57 -04:00
/**
* 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}`));
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));