Compare commits

...

12 Commits

Author SHA1 Message Date
Jason Rasmussen d8c01fe953 Merge branch 'main' into refactor/drop-changeExpiryTime 2026-06-03 18:59:10 -04:00
Timon 4a8c3b60be fix(mobile): clear album description sends null instead of empty string (#28817) 2026-06-03 18:22:19 -04:00
Timon 2190aa72a8 refactor(server): zod int validation (#28804) 2026-06-03 18:21:07 -04:00
Timon d21cb28526 fix(mobile): shared link edit sends explicit null instead of empty string (#28812)
* fix(mobile): clear shared link password

* fix(mobile): clear shared link description

* fix(mobile): clear shared link expiry
2026-06-03 18:19:35 -04:00
Timon 5c33eb3204 refactor(server)!: drop empty string to null conversion (#28808)
refactor(server): drop empty string to null conversion
2026-06-03 18:16:53 -04:00
Timon 53e4ae7fb2 Merge branch 'fix/clear-shared-link-pwd' into refactor/drop-changeExpiryTime 2026-06-03 23:40:28 +02:00
timonrieger 47bf83813d fix(mobile): clear shared link expiry 2026-06-03 23:39:37 +02:00
timonrieger bd0fc2cc86 refactor(server)!: remove changeExpiryTime 2026-06-03 23:30:36 +02:00
timonrieger e98201232a fix(mobile): clear shared link expiry 2026-06-03 23:27:51 +02:00
timonrieger 0388c534ed fix(mobile): clear shared link description 2026-06-03 23:18:09 +02:00
Mert 137687bc0f fix(web): set src for progressive video player (#28813)
set src
2026-06-03 17:07:23 -04:00
timonrieger 4ac4781bfb fix(mobile): clear shared link password 2026-06-03 22:31:03 +02:00
22 changed files with 79 additions and 166 deletions
@@ -259,17 +259,6 @@ describe('/search', () => {
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
@@ -291,18 +280,6 @@ describe('/search', () => {
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
visibility: AssetVisibility.Timeline,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
@@ -324,17 +301,6 @@ describe('/search', () => {
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -137,7 +138,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
String? description,
Optional<String?> description = const Optional.absent(),
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -11,6 +11,7 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/shared_link.provider.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -365,11 +366,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? download;
bool? upload;
bool? meta;
String? desc;
String? password;
var password = const Optional<String?>.absent();
var description = const Optional<String?>.absent();
String? slug;
DateTime? expiry;
bool? changeExpiry;
var expiry = const Optional<DateTime?>.absent();
if (allowDownload.value != existingLink!.allowDownload) {
download = allowDownload.value;
@@ -383,12 +383,16 @@ class SharedLinkEditPage extends HookConsumerWidget {
meta = showMetadata.value;
}
if (descriptionController.text != existingLink!.description) {
desc = descriptionController.text;
if (descriptionController.text != (existingLink!.description ?? '')) {
description = descriptionController.text.isEmpty
? const Optional.present(null)
: Optional.present(descriptionController.text);
}
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
if (passwordController.text != (existingLink!.password ?? '')) {
password = passwordController.text.isEmpty
? const Optional.present(null)
: Optional.present(passwordController.text);
}
if (slugController.text != (existingLink!.slug ?? "")) {
@@ -399,8 +403,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
final newExpiry = expiryAfter.value;
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
expiry = newExpiry;
changeExpiry = true;
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
}
await ref
@@ -410,11 +413,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
showMeta: meta,
allowDownload: download,
allowUpload: upload,
description: desc,
description: description,
password: password,
slug: slug,
expiresAt: expiry?.toUtc(),
changeExpiry: changeExpiry,
expiresAt: expiry,
);
if (!context.mounted) {
return;
@@ -20,6 +20,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
import 'package:openapi/api.dart' show Optional;
@RoutePage()
class RemoteAlbumPage extends ConsumerStatefulWidget {
@@ -247,10 +248,13 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
try {
final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim();
final description = newDescription.isEmpty
? const Optional<String?>.present(null)
: Optional<String?>.present(newDescription);
await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
.updateAlbum(widget.album.id, name: newTitle, description: description);
if (mounted) {
Navigator.of(
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:openapi/api.dart' show Optional;
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -153,7 +154,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
String? description,
Optional<String?> description = const Optional.absent(),
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -71,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String albumId,
UserDto owner, {
String? name,
String? description,
Optional<String?> description = const Optional.absent(),
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -86,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
albumId,
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
description: description == null ? const Optional.absent() : Optional.present(description),
description: description,
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
+6 -8
View File
@@ -88,11 +88,10 @@ class SharedLinkService {
required bool? showMeta,
required bool? allowDownload,
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
String? password,
Optional<String?> password = const Optional.absent(),
Optional<String?> description = const Optional.absent(),
String? slug,
DateTime? expiresAt,
Optional<DateTime?> expiresAt = const Optional.absent(),
}) async {
try {
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
@@ -101,11 +100,10 @@ class SharedLinkService {
showMetadata: showMeta == null ? const Optional.absent() : Optional.present(showMeta),
allowDownload: allowDownload == null ? const Optional.absent() : Optional.present(allowDownload),
allowUpload: allowUpload == null ? const Optional.absent() : Optional.present(allowUpload),
expiresAt: expiresAt == null ? const Optional.absent() : Optional.present(expiresAt),
description: description == null ? const Optional.absent() : Optional.present(description),
password: password == null ? const Optional.absent() : Optional.present(password),
password: password,
description: description,
expiresAt: expiresAt,
slug: slug == null ? const Optional.absent() : Optional.present(slug),
changeExpiryTime: changeExpiry == null ? const Optional.absent() : Optional.present(changeExpiry),
),
);
if (responseDto != null) {
+1 -18
View File
@@ -15,7 +15,6 @@ class SharedLinkEditDto {
SharedLinkEditDto({
this.allowDownload = const Optional.absent(),
this.allowUpload = const Optional.absent(),
this.changeExpiryTime = const Optional.absent(),
this.description = const Optional.absent(),
this.expiresAt = const Optional.absent(),
this.password = const Optional.absent(),
@@ -41,15 +40,6 @@ class SharedLinkEditDto {
///
Optional<bool?> allowUpload;
/// 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.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> changeExpiryTime;
/// Link description
Optional<String?> description;
@@ -75,7 +65,6 @@ class SharedLinkEditDto {
bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.changeExpiryTime == changeExpiryTime &&
other.description == description &&
other.expiresAt == expiresAt &&
other.password == password &&
@@ -87,7 +76,6 @@ class SharedLinkEditDto {
// ignore: unnecessary_parenthesis
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(password == null ? 0 : password!.hashCode) +
@@ -95,7 +83,7 @@ class SharedLinkEditDto {
(slug == null ? 0 : slug!.hashCode);
@override
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, slug=$slug]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -107,10 +95,6 @@ class SharedLinkEditDto {
final value = this.allowUpload.value;
json[r'allowUpload'] = value;
}
if (this.changeExpiryTime.isPresent) {
final value = this.changeExpiryTime.value;
json[r'changeExpiryTime'] = value;
}
if (this.description.isPresent) {
final value = this.description.value;
json[r'description'] = value;
@@ -147,7 +131,6 @@ class SharedLinkEditDto {
return SharedLinkEditDto(
allowDownload: json.containsKey(r'allowDownload') ? Optional.present(mapValueOfType<bool>(json, r'allowDownload')) : const Optional.absent(),
allowUpload: json.containsKey(r'allowUpload') ? Optional.present(mapValueOfType<bool>(json, r'allowUpload')) : const Optional.absent(),
changeExpiryTime: json.containsKey(r'changeExpiryTime') ? Optional.present(mapValueOfType<bool>(json, r'changeExpiryTime')) : const Optional.absent(),
description: json.containsKey(r'description') ? Optional.present(mapValueOfType<String>(json, r'description')) : const Optional.absent(),
expiresAt: json.containsKey(r'expiresAt') ? Optional.present(mapDateTime(json, r'expiresAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')) : const Optional.absent(),
password: json.containsKey(r'password') ? Optional.present(mapValueOfType<String>(json, r'password')) : const Optional.absent(),
-4
View File
@@ -22197,10 +22197,6 @@
"description": "Allow uploads",
"type": "boolean"
},
"changeExpiryTime": {
"description": "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.",
"type": "boolean"
},
"description": {
"description": "Link description",
"nullable": true,
-2
View File
@@ -2192,8 +2192,6 @@ export type SharedLinkEditDto = {
allowDownload?: boolean;
/** Allow uploads */
allowUpload?: boolean;
/** 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. */
changeExpiryTime?: boolean;
/** Link description */
description?: string | null;
/** Expiration date */
@@ -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()}`)
@@ -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 -4
View File
@@ -9,20 +9,22 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { asBirthDateString, asDateString } 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' });
+7 -7
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,12 +23,12 @@ 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'),
+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' });
+3 -3
View File
@@ -2,20 +2,20 @@ 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 { 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' });
+3 -9
View File
@@ -4,7 +4,7 @@ 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 { isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
import z from 'zod';
export const UserUpdateMeSchema = z
@@ -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'),
+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}`);
}
+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,
-17
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
@@ -251,16 +246,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('.', '')));
@@ -370,6 +370,7 @@
<video
bind:this={videoPlayer}
slot="media"
src={assetFileUrl}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
disablePictureInPicture