Merge branch 'main' into feat/memories-view

This commit is contained in:
Ben Beckford
2026-06-04 12:04:44 -07:00
committed by GitHub
337 changed files with 22047 additions and 4909 deletions
@@ -240,7 +240,7 @@ describe(AssetController.name, () => {
for (const [test, errors] of [
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=1' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
@@ -248,16 +248,9 @@ describe(AssetController.name, () => {
}
});
it('should convert rating 0 to null', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
expect(status).toBe(200);
});
it('should leave correct ratings as-is', async () => {
const assetId = factory.uuid();
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
for (const test of [{ rating: 1 }, { rating: 5 }]) {
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
expect(status).toBe(200);
@@ -53,16 +53,6 @@ describe(PersonController.name, () => {
await request(ctx.getHttpServer()).post('/people');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should map an empty birthDate to null', async () => {
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
});
it('should map an empty color to null', async () => {
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
});
});
describe('DELETE /people', () => {
@@ -153,12 +143,6 @@ describe(PersonController.name, () => {
);
});
it('should map an empty birthDate to null', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' });
expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null });
});
it('should not accept an invalid birth date (false)', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/people/${factory.uuid()}`)
+4 -4
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SyncAckDeleteDto, SyncAckDto, SyncAckSetDto, SyncStreamDto } from 'src/dtos/sync.dto';
@@ -27,12 +27,12 @@ export class SyncController {
'Retrieve a JSON lines streamed response of changes for synchronization. This endpoint is used by the mobile app to efficiently stay up to date with changes.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
async getSyncStream(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Body() dto: SyncStreamDto) {
try {
await this.service.stream(auth, res, dto);
} catch (error: Error | any) {
res.setHeader('Content-Type', 'application/json');
this.errorService.handleError(res, error);
this.errorService.handleError(req, res, error);
}
}
@@ -63,11 +63,5 @@ describe(TagController.name, () => {
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should allow setting a null color via an empty string', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
});
});
});
+5 -5
View File
@@ -6,7 +6,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { asDateTimeString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
import z from 'zod';
@@ -195,14 +195,14 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumName: entity.albumName,
description: entity.description,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
createdAt: asDateTimeString(entity.createdAt),
updatedAt: asDateTimeString(entity.updatedAt),
id: entity.id,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
startDate: asDateTimeString(startDate),
endDate: asDateTimeString(endDate),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
+7 -7
View File
@@ -18,7 +18,7 @@ import {
} from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { asDateString } from 'src/utils/date';
import { asDateTimeString } from 'src/utils/date';
import { mimeTypes } from 'src/utils/mime-types';
import z from 'zod';
@@ -199,7 +199,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
type: entity.type,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: asDateString(entity.localDateTime),
localDateTime: asDateTimeString(entity.localDateTime),
duration: entity.duration,
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
@@ -211,7 +211,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
return {
id: entity.id,
createdAt: asDateString(entity.createdAt),
createdAt: asDateTimeString(entity.createdAt),
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
libraryId: entity.libraryId,
@@ -220,10 +220,10 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
originalFileName: entity.originalFileName,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
fileCreatedAt: asDateString(entity.fileCreatedAt),
fileModifiedAt: asDateString(entity.fileModifiedAt),
localDateTime: asDateString(entity.localDateTime),
updatedAt: asDateString(entity.updatedAt),
fileCreatedAt: asDateTimeString(entity.fileCreatedAt),
fileModifiedAt: asDateTimeString(entity.fileModifiedAt),
localDateTime: asDateTimeString(entity.localDateTime),
updatedAt: asDateTimeString(entity.updatedAt),
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
isArchived: entity.visibility === AssetVisibility.Archive,
isTrashed: !!entity.deletedAt,
+2 -3
View File
@@ -14,11 +14,9 @@ const UpdateAssetBaseSchema = z
latitude: latitudeSchema.optional().describe('Latitude coordinate'),
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
rating: z
.number()
.int()
.min(-1)
.min(1)
.max(5)
.transform((value) => (value === 0 ? null : value))
.nullish()
.describe('Rating in range [1-5], or null for unrated')
.meta({
@@ -26,6 +24,7 @@ const UpdateAssetBaseSchema = z
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.updated('v3', 'Using -1 as a rating is no longer valid.')
.getExtensions(),
}),
description: z.string().optional().describe('Asset description'),
+4 -4
View File
@@ -1,7 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import { Exif } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { asDateTimeString } from 'src/utils/date';
import z from 'zod';
export const ExifResponseSchema = z
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.int().nullish().default(null).describe('Rating'),
rating: z.int().min(1).max(5).nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });
@@ -44,8 +44,8 @@ export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
modifyDate: asDateString(entity.modifyDate),
dateTimeOriginal: asDateTimeString(entity.dateTimeOriginal),
modifyDate: asDateTimeString(entity.modifyDate),
timeZone: entity.timeZone,
lensModel: entity.lensModel,
fNumber: entity.fNumber,
+9 -7
View File
@@ -7,22 +7,24 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceTypeSchema } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { asBirthDateString, asDateString } from 'src/utils/date';
import { asDateString, asDateTimeString } from 'src/utils/date';
import { transformFaceBoundingBox } from 'src/utils/transform';
import { emptyStringToNull, hexColor, stringToBool } from 'src/validation';
import { hexColor, stringToBool } from 'src/validation';
import z from 'zod';
const PersonCreateSchema = z
.object({
name: z.string().optional().describe('Person name'),
// Note: the mobile app cannot currently set the birth date to null.
birthDate: emptyStringToNull(z.string().meta({ format: 'date' }).nullable())
birthDate: z
.string()
.meta({ format: 'date' })
.nullable()
.optional()
.refine((val) => (val ? new Date(val) <= new Date() : true), { error: 'Birth date cannot be in the future' })
.describe('Person date of birth'),
isHidden: z.boolean().optional().describe('Person visibility (hidden)'),
isFavorite: z.boolean().optional().describe('Mark as favorite'),
color: emptyStringToNull(hexColor.nullable()).optional().describe('Person color (hex)'),
color: hexColor.nullable().optional().describe('Person color (hex)'),
})
.meta({ id: 'PersonCreateDto' });
@@ -173,12 +175,12 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
return {
id: person.id,
name: person.name,
birthDate: asBirthDateString(person.birthDate),
birthDate: asDateString(person.birthDate),
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateString(person.updatedAt),
updatedAt: asDateTimeString(person.updatedAt),
};
}
+9 -8
View File
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema } from 'src/dtos/album.dto';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
import { emptyStringToNull, isoDatetimeToDate, stringToBool } from 'src/validation';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import z from 'zod';
const BaseSearchSchema = z.object({
@@ -23,19 +23,19 @@ const BaseSearchSchema = z.object({
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
city: emptyStringToNull(z.string().nullable()).optional().describe('Filter by city name'),
state: emptyStringToNull(z.string().nullable()).optional().describe('Filter by state/province name'),
country: emptyStringToNull(z.string().nullable()).optional().describe('Filter by country name'),
make: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera make'),
model: emptyStringToNull(z.string().nullable()).optional().describe('Filter by camera model'),
lensModel: emptyStringToNull(z.string().nullable()).optional().describe('Filter by lens model'),
city: z.string().nullable().optional().describe('Filter by city name'),
state: z.string().nullable().optional().describe('Filter by state/province name'),
country: z.string().nullable().optional().describe('Filter by country name'),
make: z.string().nullable().optional().describe('Filter by camera make'),
model: z.string().nullable().optional().describe('Filter by camera model'),
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.int()
.min(-1)
.min(1)
.max(5)
.nullish()
.describe('Filter by rating [1-5], or null for unrated')
@@ -44,6 +44,7 @@ const BaseSearchSchema = z.object({
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.updated('v3', 'Using -1 as a rating is no longer valid.')
.getExtensions(),
}),
ocr: z.string().optional().describe('Filter by OCR text content'),
+7 -13
View File
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkTypeSchema } from 'src/enum';
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
const SharedLinkSearchSchema = z
@@ -23,9 +23,9 @@ const SharedLinkCreateSchema = z
type: SharedLinkTypeSchema,
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs (for individual assets)'),
albumId: z.uuidv4().optional().describe('Album ID (for album sharing)'),
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
description: z.string().nullable().optional().describe('Link description'),
password: z.string().nullable().optional().describe('Link password'),
slug: z.string().nullable().optional().describe('Custom URL slug'),
expiresAt: isoDatetimeToDate.nullable().describe('Expiration date').default(null).optional(),
allowUpload: z.boolean().optional().describe('Allow uploads'),
allowDownload: z.boolean().default(true).optional().describe('Allow downloads'),
@@ -35,19 +35,13 @@ const SharedLinkCreateSchema = z
const SharedLinkEditSchema = z
.object({
description: emptyStringToNull(z.string().nullable()).optional().describe('Link description'),
password: emptyStringToNull(z.string().nullable()).optional().describe('Link password'),
slug: emptyStringToNull(z.string().nullable()).optional().describe('Custom URL slug'),
description: z.string().nullable().optional().describe('Link description'),
password: z.string().nullable().optional().describe('Link password'),
slug: z.string().nullable().optional().describe('Custom URL slug'),
expiresAt: isoDatetimeToDate.nullish().describe('Expiration date'),
allowUpload: z.boolean().optional().describe('Allow uploads'),
allowDownload: z.boolean().optional().describe('Allow downloads'),
showMetadata: z.boolean().optional().describe('Show metadata'),
changeExpiryTime: z
.boolean()
.optional()
.describe(
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
),
})
.meta({ id: 'SharedLinkEditDto' });
+6 -6
View File
@@ -1,21 +1,21 @@
import { createZodDto } from 'nestjs-zod';
import { Tag } from 'src/database';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { emptyStringToNull, hexColor } from 'src/validation';
import { asDateTimeString } from 'src/utils/date';
import { hexColor } from 'src/validation';
import z from 'zod';
const TagCreateSchema = z
.object({
name: z.string().describe('Tag name'),
parentId: z.uuidv4().nullish().describe('Parent tag ID'),
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
color: hexColor.nullable().optional().describe('Tag color (hex)'),
})
.meta({ id: 'TagCreateDto' });
const TagUpdateSchema = z
.object({
color: emptyStringToNull(hexColor.nullable()).optional().describe('Tag color (hex)'),
color: hexColor.nullable().optional().describe('Tag color (hex)'),
})
.meta({ id: 'TagUpdateDto' });
@@ -65,8 +65,8 @@ export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
parentId: entity.parentId ?? undefined,
name: entity.value.split('/').at(-1) as string,
value: entity.value,
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
createdAt: asDateTimeString(entity.createdAt),
updatedAt: asDateTimeString(entity.updatedAt),
color: entity.color ?? undefined,
};
}
+5 -11
View File
@@ -3,8 +3,8 @@ import { User, UserAdmin } from 'src/database';
import { pinCodeRegex } from 'src/dtos/auth.dto';
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
import { asDateString } from 'src/utils/date';
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
import { asDateTimeString } from 'src/utils/date';
import { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
import z from 'zod';
export const UserUpdateMeSchema = z
@@ -61,7 +61,7 @@ export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponse
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
profileChangedAt: asDateString(entity.profileChangedAt),
profileChangedAt: asDateTimeString(entity.profileChangedAt),
};
};
@@ -80,10 +80,7 @@ export const UserAdminCreateSchema = z
password: z.string().describe('User password'),
name: z.string().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(),
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
.optional()
.describe('PIN code')
.meta({ example: '123456' }),
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
quotaSizeInBytes: z.int().min(0).nullish().describe('Storage quota in bytes'),
shouldChangePassword: z.boolean().optional().describe('Require password change on next login'),
@@ -98,10 +95,7 @@ const UserAdminUpdateSchema = z
.object({
email: toEmail.optional().describe('User email'),
password: z.string().optional().describe('User password'),
pinCode: emptyStringToNull(z.string().regex(pinCodeRegex).nullable())
.optional()
.describe('PIN code')
.meta({ example: '123456' }),
pinCode: z.string().regex(pinCodeRegex).nullable().optional().describe('PIN code').meta({ example: '123456' }),
name: z.string().optional().describe('User name'),
avatarColor: UserAvatarColorSchema.nullish(),
storageLabel: z.string().pipe(sanitizeFilename).nullish().describe('Storage label'),
+6 -4
View File
@@ -1,14 +1,14 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable, catchError, throwError } from 'rxjs';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { isHttpException, onRequestError } from 'src/utils/logger';
import { routeToErrorMessage } from 'src/utils/misc';
@Injectable()
@@ -18,14 +18,16 @@ export class ErrorInterceptor implements NestInterceptor {
}
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const req = context.switchToHttp().getRequest<Request>();
return next.handle().pipe(
catchError((error) =>
throwError(() => {
if (error instanceof HttpException) {
if (isHttpException(error)) {
return error;
}
logGlobalError(this.logger, error);
onRequestError(req, error, this.logger);
const message = routeToErrorMessage(context.getHandler().name);
return new InternalServerErrorException(message);
@@ -96,7 +96,11 @@ export class FileUploadInterceptor implements NestInterceptor {
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
if ('code' in error && error.code === 'ECONNRESET') {
this.logger.debug('Upload was cancelled');
} else {
this.logger.error(`Upload failed with: ${error}`);
}
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
@@ -1,10 +1,10 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { onRequestError } from 'src/utils/logger';
import { ZodError } from 'zod';
@Catch()
@@ -17,10 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
this.handleError(host.switchToHttp().getResponse<Response>(), error);
const http = host.switchToHttp();
this.handleError(http.getRequest<Request>(), http.getResponse<Response>(), error);
}
handleError(res: Response, error: Error) {
handleError(req: Request, res: Response, error: Error) {
onRequestError(req, error, this.logger);
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
@@ -28,8 +31,6 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
private fromError(error: Error) {
logGlobalError(this.logger, error);
if (error instanceof HttpException) {
const status = error.getStatus();
const response = error.getResponse();
+15 -3
View File
@@ -24,7 +24,7 @@ import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
import z from 'zod';
export let cachedVectorExtension: VectorExtension | undefined;
export async function getVectorExtension(runner: Kysely<DB>): Promise<VectorExtension> {
@@ -292,7 +292,13 @@ export class DatabaseRepository {
`.execute(this.db);
const dimSize = rows[0]?.dimsize;
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
if (
!z
.int()
.min(1)
.max(2 ** 16)
.safeParse(dimSize).success
) {
this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`);
return 512;
}
@@ -300,7 +306,13 @@ export class DatabaseRepository {
}
async setDimensionSize(dimSize: number): Promise<void> {
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
if (
!z
.int()
.min(1)
.max(2 ** 16)
.safeParse(dimSize).success
) {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
}
+3 -3
View File
@@ -8,7 +8,7 @@ import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
import z from 'zod';
export interface SearchAssetIdOptions {
checksum?: Buffer;
@@ -278,7 +278,7 @@ export class SearchRepository {
],
})
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
if (!z.int().min(1).max(1000).safeParse(pagination.size).success) {
throw new Error(`Invalid value for 'size': ${pagination.size}`);
}
@@ -313,7 +313,7 @@ export class SearchRepository {
],
})
searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) {
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
if (!z.int().min(1).max(1000).safeParse(numResults).success) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}
+7 -7
View File
@@ -18,7 +18,7 @@ import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { asDateString } from 'src/utils/date';
import { asDateTimeString } from 'src/utils/date';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
@@ -59,11 +59,11 @@ export class AlbumService extends BaseService {
return albums.map((album) => ({
...mapAlbum(album),
sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
startDate: asDateTimeString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateTimeString(albumMetadata[album.id]?.endDate ?? undefined),
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
lastModifiedAssetTimestamp: asDateTimeString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
}));
}
@@ -79,10 +79,10 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
startDate: asDateTimeString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateTimeString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
lastModifiedAssetTimestamp: asDateTimeString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
};
}
@@ -1610,22 +1610,6 @@ describe(MetadataService.name, () => {
);
});
it('should handle valid negative rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
exif: expect.objectContaining({
rating: -1,
}),
lockedPropertiesBehavior: 'skip',
}),
);
});
it('should handle livePhotoCID not set', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
+1 -1
View File
@@ -305,7 +305,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, 1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
+1 -1
View File
@@ -124,7 +124,7 @@ export class SharedLinkService extends BaseService {
userId: auth.user.id,
description: dto.description,
password: dto.password,
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload,
showExif: dto.showMetadata,
+33
View File
@@ -0,0 +1,33 @@
import { asDateString, asDateTimeString } from 'src/utils/date';
import { describe, expect, it } from 'vitest';
describe('asDateString', () => {
it('should return null for null input', () => {
expect(asDateString(null)).toBeNull();
});
it('should pass through a pre-serialized string unchanged', () => {
expect(asDateString('2000-01-15')).toBe('2000-01-15');
});
it('should return the local calendar date, not the UTC date', () => {
const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight
expect(asDateString(date)).toBe('2000-01-15');
});
});
describe('asDateTimeString', () => {
it('should return null for null input', () => {
expect(asDateTimeString(null)).toBeNull();
});
it('should pass through a pre-serialized string unchanged', () => {
const iso = '2000-01-15T12:00:00.000Z';
expect(asDateTimeString(iso)).toBe(iso);
});
it('should return an ISO 8601 datetime string for a Date', () => {
const date = new Date('2000-01-15T12:00:00.000Z');
expect(asDateTimeString(date)).toBe('2000-01-15T12:00:00.000Z');
});
});
+6 -11
View File
@@ -1,23 +1,18 @@
import { DateTime } from 'luxon';
import { isoDateToDate, isoDatetimeToDate } from 'src/validation';
/**
* Convert a date to a ISO 8601 datetime string.
* @param x - The date to convert.
* @returns The ISO 8601 datetime string.
* @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDatetimeToDate` in validation.ts will handle the conversion instead.
*/
export const asDateString = <T extends Date | string | undefined | null>(x: T) => {
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
export const asDateTimeString = <T extends Date | string | undefined | null>(x: T) => {
return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude<T, Date>);
};
/**
* Convert a date to a date string.
* @param x - The date to convert.
* @returns The date string.
* @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDateToDate` in validation.ts will handle the conversion instead.
* Convert a date to a date string (yyyy-mm-dd).
*/
export const asBirthDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? x.toISOString().split('T')[0] : x;
export const asDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? isoDateToDate.encode(x) : x;
};
export const extractTimeZone = (dateTimeOriginal?: string | null) => {
+1 -1
View File
@@ -78,7 +78,7 @@ describe('duplicate utils', () => {
model: null,
latitude: undefined,
city: '',
rating: 0,
rating: null,
});
// fileSizeInByte (1000) + make ('Canon') = 2 truthy values
// model (null), latitude (undefined), city (''), rating (0) are all falsy
+11 -2
View File
@@ -1,14 +1,23 @@
import { HttpException } from '@nestjs/common';
import { Request } from 'express';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const logGlobalError = (logger: LoggingRepository, error: Error) => {
if (error instanceof HttpException) {
const isRequestAborted = (request: Request) => request.destroyed === true && request.complete === false;
export const isHttpException = (error: Error): error is HttpException => error instanceof HttpException;
export const onRequestError = (req: Request, error: Error, logger: LoggingRepository) => {
if (isHttpException(error)) {
const status = error.getStatus();
const response = error.getResponse();
logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`);
return;
}
if (isRequestAborted(req)) {
logger.debug(`Client aborted request: ${error}`);
return;
}
if (error instanceof Error) {
logger.error(`Unknown error: ${error}`, error?.stack);
return;
+6 -18
View File
@@ -125,11 +125,6 @@ const FilenameParamSchema = z.object({
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
@@ -171,7 +166,12 @@ export const isoDateToDate = z
z.date(),
{
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString().slice(0, 10),
encode: (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
},
},
)
.meta({ example: '2024-01-01' });
@@ -251,16 +251,4 @@ export const hexColor = z
.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('.', '')));