mirror of
https://github.com/immich-app/immich.git
synced 2026-06-06 14:55:17 -04:00
Merge branch 'main' into feat/memories-view
This commit is contained in:
@@ -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()}`)
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('.', '')));
|
||||
|
||||
Reference in New Issue
Block a user