From 9e49783e491a55abc55d36291313ce825c808bb3 Mon Sep 17 00:00:00 2001 From: Rudhra Raveendran Date: Thu, 10 Apr 2025 07:13:50 -0700 Subject: [PATCH 01/22] feat: use browser download manager for single file downloads (#17507) * Fix download panel reactivity * Directly download individual files without buffering in memory * Fix shared link e2e download tests --- e2e/src/web/specs/shared-link.e2e-spec.ts | 3 ++- web/src/lib/stores/download.ts | 1 + web/src/lib/utils/asset-utils.ts | 31 ++++++++++------------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 9313526dab..562a0b4e8c 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -48,7 +48,7 @@ test.describe('Shared Links', () => { await page.waitForSelector('[data-group] svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); - await page.getByText('DOWNLOADING', { exact: true }).waitFor(); + await page.waitForEvent('download'); }); test('download all from shared link', async ({ page }) => { @@ -56,6 +56,7 @@ test.describe('Shared Links', () => { await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('button', { name: 'Download' }).click(); await page.getByText('DOWNLOADING', { exact: true }).waitFor(); + await page.waitForEvent('download'); }); test('enter password for a shared link', async ({ page }) => { diff --git a/web/src/lib/stores/download.ts b/web/src/lib/stores/download.ts index ac57c76153..fc450d95ef 100644 --- a/web/src/lib/stores/download.ts +++ b/web/src/lib/stores/download.ts @@ -29,6 +29,7 @@ const update = (key: string, value: Partial | null) => { const item = newState[key]; Object.assign(item, value); item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); + newState[key] = { ...item }; return newState; }); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 1dcd2c95b1..c3b23e1e93 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -162,6 +162,18 @@ export const downloadBlob = (data: Blob, filename: string) => { URL.revokeObjectURL(url); }; +export const downloadUrl = (url: string, filename: string) => { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + + document.body.append(anchor); + anchor.click(); + anchor.remove(); + + URL.revokeObjectURL(url); +}; + export const downloadArchive = async (fileName: string, options: Omit) => { const $preferences = get(preferences); const dto = { ...options, archiveSize: $preferences?.download.archiveSize }; @@ -238,12 +250,8 @@ export const downloadFile = async (asset: AssetResponseDto) => { } } - for (const { filename, id, size } of assets) { - const downloadKey = filename; - + for (const { filename, id } of assets) { try { - const abort = new AbortController(); - downloadManager.add(downloadKey, size, abort); const key = getKey(); notificationController.show({ @@ -251,20 +259,9 @@ export const downloadFile = async (asset: AssetResponseDto) => { message: $t('downloading_asset_filename', { values: { filename: asset.originalFileName } }), }); - // TODO use sdk once it supports progress events - const { data } = await downloadRequest({ - method: 'GET', - url: getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''), - signal: abort.signal, - onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total), - }); - - downloadBlob(data, filename); + downloadUrl(getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''), filename); } catch (error) { handleError(error, $t('errors.error_downloading', { values: { filename } })); - downloadManager.clear(downloadKey); - } finally { - setTimeout(() => downloadManager.clear(downloadKey), 5000); } } }; From 94dba292989762cfb7aa06ee0be3367831efe5d0 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 10 Apr 2025 15:53:21 +0100 Subject: [PATCH 02/22] refactor: remove user entity (#17498) --- server/src/db.d.ts | 6 +-- server/src/dtos/auth.dto.ts | 5 +- server/src/dtos/user.dto.ts | 7 ++- server/src/entities/album-user.entity.ts | 4 +- server/src/entities/album.entity.ts | 4 +- server/src/entities/asset.entity.ts | 5 +- server/src/entities/person.entity.ts | 2 - server/src/entities/shared-link.entity.ts | 2 - server/src/entities/stack.entity.ts | 2 - server/src/entities/user.entity.ts | 34 ------------ server/src/queries/user.repository.sql | 57 +++++++++++++++++--- server/src/repositories/user.repository.ts | 63 ++++++++++++---------- server/src/services/auth.service.spec.ts | 30 +++++------ server/src/services/auth.service.ts | 8 +-- server/src/services/base.service.ts | 4 +- server/src/services/person.service.spec.ts | 4 +- server/src/services/user.service.spec.ts | 8 +-- server/src/services/user.service.ts | 7 +-- server/test/fixtures/asset.stub.ts | 1 - server/test/fixtures/person.stub.ts | 10 ---- server/test/fixtures/shared-link.stub.ts | 7 +-- server/test/fixtures/user.stub.ts | 32 ++++------- 22 files changed, 145 insertions(+), 157 deletions(-) delete mode 100644 server/src/entities/user.entity.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 727b0d51e4..f5a35e3b4a 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -17,7 +17,7 @@ import { SyncEntityType, } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; -import { OnThisDayData } from 'src/types'; +import {OnThisDayData, UserMetadataItem} from 'src/types'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -412,10 +412,8 @@ export interface TypeormMetadata { value: string | null; } -export interface UserMetadata { - key: string; +export interface UserMetadata extends UserMetadataItem { userId: string; - value: Json; } export interface UsersAudit { diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 334b7a49b5..7f2ffa5878 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; -import { UserEntity } from 'src/entities/user.entity'; +import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { ImmichCookie } from 'src/enum'; import { toEmail } from 'src/validation'; @@ -42,7 +41,7 @@ export class LoginResponseDto { shouldChangePassword!: boolean; } -export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { +export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto { return { accessToken, userId: entity.id, diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 851d4d3921..72e5c83b35 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; -import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; import { getPreferences } from 'src/utils/preferences'; @@ -42,13 +41,13 @@ export class UserLicense { activatedAt!: Date; } -export const mapUser = (entity: UserEntity | User): UserResponseDto => { +export const mapUser = (entity: User | UserAdmin): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity.email, (entity as UserEntity).metadata || []).avatar.color, + avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color, profileChangedAt: entity.profileChangedAt, }; }; @@ -142,7 +141,7 @@ export class UserAdminResponseDto extends UserResponseDto { license!: UserLicense | null; } -export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto { +export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const metadata = entity.metadata || []; const license = metadata.find( (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts index 7950ffab7d..b27c68dc74 100644 --- a/server/src/entities/album-user.entity.ts +++ b/server/src/entities/album-user.entity.ts @@ -1,11 +1,11 @@ +import { User } from 'src/database'; import { AlbumEntity } from 'src/entities/album.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AlbumUserRole } from 'src/enum'; export class AlbumUserEntity { albumId!: string; userId!: string; album!: AlbumEntity; - user!: UserEntity; + user!: User; role!: AlbumUserRole; } diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 946c807a1a..42f5b5fa02 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -1,12 +1,12 @@ +import { User } from 'src/database'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetOrder } from 'src/enum'; export class AlbumEntity { id!: string; - owner!: UserEntity; + owner!: User; ownerId!: string; albumName!: string; description!: string; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index ef27e0db5f..def9a92ccd 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,6 +1,6 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { Tag } from 'src/database'; +import { Tag, User } from 'src/database'; import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -9,7 +9,6 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; @@ -20,7 +19,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export class AssetEntity { id!: string; deviceAssetId!: string; - owner!: UserEntity; + owner!: User; ownerId!: string; libraryId?: string | null; deviceId!: string; diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 6ea97b21bc..76174443f9 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -1,5 +1,4 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { UserEntity } from 'src/entities/user.entity'; export class PersonEntity { id!: string; @@ -7,7 +6,6 @@ export class PersonEntity { updatedAt!: Date; updateId?: string; ownerId!: string; - owner!: UserEntity; name!: string; birthDate!: Date | string | null; thumbnailPath!: string; diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index 5ce0247be7..720ba424d1 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -1,6 +1,5 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { SharedLinkType } from 'src/enum'; export class SharedLinkEntity { @@ -8,7 +7,6 @@ export class SharedLinkEntity { description!: string | null; password!: string | null; userId!: string; - user!: UserEntity; key!: Buffer; // use to access the inidividual asset type!: SharedLinkType; createdAt!: Date; diff --git a/server/src/entities/stack.entity.ts b/server/src/entities/stack.entity.ts index 8b8fd94f38..b0dc79f7eb 100644 --- a/server/src/entities/stack.entity.ts +++ b/server/src/entities/stack.entity.ts @@ -1,9 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; export class StackEntity { id!: string; - owner!: UserEntity; ownerId!: string; assets!: AssetEntity[]; primaryAsset!: AssetEntity; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts deleted file mode 100644 index 96c574c83d..0000000000 --- a/server/src/entities/user.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ExpressionBuilder } from 'kysely'; -import { jsonArrayFrom } from 'kysely/helpers/postgres'; -import { DB } from 'src/db'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; - -export class UserEntity { - id!: string; - name!: string; - isAdmin!: boolean; - email!: string; - storageLabel!: string | null; - password?: string; - oauthId!: string; - profileImagePath!: string; - shouldChangePassword!: boolean; - createdAt!: Date; - deletedAt!: Date | null; - status!: UserStatus; - updatedAt!: Date; - updateId?: string; - assets!: AssetEntity[]; - quotaSizeInBytes!: number | null; - quotaUsageInBytes!: number; - metadata!: UserMetadataItem[]; - profileChangedAt!: Date; -} - -export const withMetadata = (eb: ExpressionBuilder) => { - return jsonArrayFrom( - eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'), - ).as('metadata'); -}; diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index a726ab46e9..b7d958aa0f 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -24,7 +24,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where @@ -54,7 +55,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -87,7 +102,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -135,7 +164,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -174,7 +217,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where @@ -210,7 +254,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 5912f60687..1f42b8b194 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; -import { columns, UserAdmin } from 'src/database'; +import { columns } from 'src/database'; import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { AssetType, UserStatus } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; @@ -32,12 +32,21 @@ export interface UserFindOptions { withDeleted?: boolean; } +const withMetadata = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom('user_metadata') + .select(['user_metadata.key', 'user_metadata.value']) + .whereRef('users.id', '=', 'user_metadata.userId'), + ).as('metadata'); +}; + @Injectable() export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) - get(userId: string, options: UserFindOptions): Promise { + get(userId: string, options: UserFindOptions) { options = options || {}; return this.db @@ -46,7 +55,7 @@ export class UserRepository { .select(withMetadata) .where('users.id', '=', userId) .$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } getMetadata(userId: string) { @@ -58,13 +67,14 @@ export class UserRepository { } @GenerateSql() - getAdmin(): Promise { + getAdmin() { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .where('users.isAdmin', '=', true) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql() @@ -80,34 +90,36 @@ export class UserRepository { } @GenerateSql({ params: [DummyValue.EMAIL] }) - getByEmail(email: string, withPassword?: boolean): Promise { + getByEmail(email: string, withPassword?: boolean) { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .$if(!!withPassword, (eb) => eb.select('password')) .where('email', '=', email) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByStorageLabel(storageLabel: string): Promise { + getByStorageLabel(storageLabel: string) { return this.db .selectFrom('users') .select(columns.userAdmin) .where('users.storageLabel', '=', storageLabel) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByOAuthId(oauthId: string): Promise { + getByOAuthId(oauthId: string) { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .where('users.oauthId', '=', oauthId) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DateTime.now().minus({ years: 1 })] }) @@ -126,18 +138,19 @@ export class UserRepository { .select(withMetadata) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .orderBy('createdAt', 'desc') - .execute() as Promise; + .execute(); } - async create(dto: Insertable): Promise { + async create(dto: Insertable) { return this.db .insertInto('users') .values(dto) .returning(columns.userAdmin) - .executeTakeFirst() as unknown as Promise; + .returning(withMetadata) + .executeTakeFirstOrThrow(); } - update(id: string, dto: Updateable): Promise { + update(id: string, dto: Updateable) { return this.db .updateTable('users') .set(dto) @@ -145,17 +158,17 @@ export class UserRepository { .where('users.deletedAt', 'is', null) .returning(columns.userAdmin) .returning(withMetadata) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirstOrThrow(); } - restore(id: string): Promise { + restore(id: string) { return this.db .updateTable('users') .set({ status: UserStatus.ACTIVE, deletedAt: null }) .where('users.id', '=', asUuid(id)) .returning(columns.userAdmin) .returning(withMetadata) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirstOrThrow(); } async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { @@ -175,14 +188,10 @@ export class UserRepository { await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); } - delete(user: { id: string }, hard?: boolean): Promise { + delete(user: { id: string }, hard?: boolean) { return hard - ? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise) - : (this.db - .updateTable('users') - .set({ deletedAt: new Date() }) - .where('id', '=', user.id) - .execute() as unknown as Promise); + ? this.db.deleteFrom('users').where('id', '=', user.id).execute() + : this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute(); } @GenerateSql() diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3c8bfa7d95..32274dacd3 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { UserAdmin } from 'src/database'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; @@ -89,7 +89,7 @@ describe('AuthService', () => { }); it('should check the user has a password', async () => { - mocks.user.getByEmail.mockResolvedValue({} as UserEntity); + mocks.user.getByEmail.mockResolvedValue({} as UserAdmin); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); @@ -97,7 +97,7 @@ describe('AuthService', () => { }); it('should successfully log the user in', async () => { - const user = { ...factory.user(), password: 'immich_password' } as UserEntity; + const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; const session = factory.session(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -124,7 +124,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', - } as UserEntity); + } as UserAdmin & { password: string }); mocks.user.update.mockResolvedValue(userStub.user1); await sut.changePassword(auth, dto); @@ -143,7 +143,7 @@ describe('AuthService', () => { }); it('should throw when password does not match existing password', async () => { - const auth = { user: { email: 'test@imimch.com' } as UserEntity }; + const auth = { user: { email: 'test@imimch.com' } as UserAdmin }; const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -151,7 +151,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', - } as UserEntity); + } as UserAdmin & { password: string }); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); @@ -163,7 +163,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', - } as UserEntity); + } as UserAdmin & { password: string }); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); @@ -217,7 +217,7 @@ describe('AuthService', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { - mocks.user.getAdmin.mockResolvedValue({} as UserEntity); + mocks.user.getAdmin.mockResolvedValue({} as UserAdmin); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); @@ -231,7 +231,7 @@ describe('AuthService', () => { id: 'admin', createdAt: new Date('2021-01-01'), metadata: [] as UserMetadataItem[], - } as UserEntity); + } as unknown as UserAdmin); await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ avatarColor: expect.any(String), @@ -294,7 +294,7 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any); await expect( sut.authenticate({ @@ -306,7 +306,7 @@ describe('AuthService', () => { }); it('should not accept a key on a non-shared route', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any); await expect( sut.authenticate({ @@ -318,7 +318,7 @@ describe('AuthService', () => { }); it('should not accept a key without a user', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any); mocks.user.get.mockResolvedValue(void 0); await expect( @@ -331,7 +331,7 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any); mocks.user.get.mockResolvedValue(userStub.admin); await expect( @@ -348,7 +348,7 @@ describe('AuthService', () => { }); it('should accept a hex key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any); mocks.user.get.mockResolvedValue(userStub.admin); await expect( @@ -717,7 +717,7 @@ describe('AuthService', () => { const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); - mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin); await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( BadRequestException, diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 4110427b0c..ae417670a9 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -4,6 +4,7 @@ import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { UserAdmin } from 'src/database'; import { OnEvent } from 'src/decorators'; import { AuthDto, @@ -17,7 +18,6 @@ import { mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; import { BaseService } from 'src/services/base.service'; @@ -190,7 +190,7 @@ export class AuthService extends BaseService { const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url)); const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); - let user = await this.userRepository.getByOAuthId(profile.sub); + let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); // link by email if (!user && profile.email) { @@ -318,7 +318,7 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid API key'); } - private validatePassword(inputPassword: string, user: UserEntity): boolean { + private validatePassword(inputPassword: string, user: { password?: string }): boolean { if (!user || !user.password) { return false; } @@ -347,7 +347,7 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } - private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6739678561..b985ef8352 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -4,7 +4,7 @@ import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -138,7 +138,7 @@ export class BaseService { return checkAccess(this.accessRepository, request); } - async createUser(dto: Insertable & { email: string }): Promise { + async createUser(dto: Insertable & { email: string }): Promise { const user = await this.userRepository.getByEmail(dto.email); if (user) { throw new BadRequestException('User exists'); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 1d8cdfd3b9..7012bc538b 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -13,6 +13,7 @@ import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { @@ -1279,7 +1280,8 @@ describe(PersonService.name, () => { describe('mapFace', () => { it('should map a face', () => { - expect(mapFaces(faceStub.face1, { user: personStub.withName.owner })).toEqual({ + const authDto = factory.auth({ id: faceStub.face1.person.ownerId }); + expect(mapFaces(faceStub.face1, authDto)).toEqual({ boundingBoxX1: 0, boundingBoxX2: 1, boundingBoxY1: 0, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index c2433f13cb..df00fdfaa4 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; @@ -214,7 +214,7 @@ describe(UserService.name, () => { describe('handleUserDelete', () => { it('should skip users not ready for deletion', async () => { - const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; + const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserAdmin; mocks.user.get.mockResolvedValue(user); @@ -225,7 +225,7 @@ describe(UserService.name, () => { }); it('should delete the user and associated assets', async () => { - const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserAdmin; const options = { force: true, recursive: true }; mocks.user.get.mockResolvedValue(user); @@ -242,7 +242,7 @@ describe(UserService.name, () => { }); it('should delete the library path for a storage label', async () => { - const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserAdmin; mocks.user.get.mockResolvedValue(user); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index d1859ed419..327328eb1c 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Updateable } from 'kysely'; import { DateTime } from 'luxon'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; @@ -8,9 +9,9 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; +import { UserTable } from 'src/schema/tables/user.table'; import { BaseService } from 'src/services/base.service'; import { JobOf, UserMetadataItem } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; @@ -49,7 +50,7 @@ export class UserService extends BaseService { } } - const update: Partial = { + const update: Updateable = { email: dto.email, name: dto.name, }; @@ -229,7 +230,7 @@ export class UserService extends BaseService { return JobStatus.SUCCESS; } - private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { + private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean { if (!user.deletedAt) { return false; } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 72016e9862..afdbaad8dd 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -41,7 +41,6 @@ export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity = return { id: stackId, assets, - owner: assets[0].owner, ownerId: assets[0].ownerId, primaryAsset: assets[0], primaryAssetId: assets[0].id, diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index ecd5b0dbea..3d5e031216 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -7,7 +7,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -22,7 +21,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -37,7 +35,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -52,7 +49,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: '1976-06-30', thumbnailPath: '/path/to/thumbnail.jpg', @@ -67,7 +63,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '', @@ -82,7 +77,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/new/path/to/thumbnail.jpg', @@ -97,7 +91,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -112,7 +105,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 2', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -127,7 +119,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -142,7 +133,6 @@ export const personStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 739d6c5b93..4d2c509359 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,10 +1,10 @@ +import { UserAdmin } from 'src/database'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -106,7 +106,6 @@ export const sharedLinkStub = { individual: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.INDIVIDUAL, createdAt: today, @@ -154,7 +153,6 @@ export const sharedLinkStub = { readonlyNoExif: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.ALBUM, createdAt: today, @@ -185,7 +183,7 @@ export const sharedLinkStub = { { id: 'id_1', status: AssetStatus.ACTIVE, - owner: undefined as unknown as UserEntity, + owner: undefined as unknown as UserAdmin, ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', deviceId: 'device_id_1', @@ -253,7 +251,6 @@ export const sharedLinkStub = { passwordRequired: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.ALBUM, createdAt: today, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 844b8c61b9..0dd5409602 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,13 +1,12 @@ -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { - admin: Object.freeze({ + admin: { ...authStub.admin.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), - password: 'admin_password', name: 'admin_name', id: 'admin_id', storageLabel: 'admin', @@ -17,16 +16,14 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - user1: Object.freeze({ + }, + user1: { ...authStub.user1.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), - password: 'immich_password', name: 'immich_name', storageLabel: null, oauthId: '', @@ -35,7 +32,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], metadata: [ { key: UserMetadataKey.PREFERENCES, @@ -44,13 +40,12 @@ export const userStub = { ], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - user2: Object.freeze({ + }, + user2: { ...authStub.user2.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), metadata: [], - password: 'immich_password', name: 'immich_name', storageLabel: null, oauthId: '', @@ -59,16 +54,14 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - storageLabel: Object.freeze({ + }, + storageLabel: { ...authStub.user1.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), metadata: [], - password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', oauthId: '', @@ -77,16 +70,14 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - profilePath: Object.freeze({ + }, + profilePath: { ...authStub.user1.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), metadata: [], - password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', oauthId: '', @@ -95,8 +86,7 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), + }, }; From 8aea07b750c6060959370eb74a25cdda16d4d13f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 10 Apr 2025 11:53:21 -0400 Subject: [PATCH 03/22] refactor: album user entity (#17524) --- server/src/database.ts | 7 ++++++- server/src/entities/album-user.entity.ts | 11 ----------- server/src/entities/album.entity.ts | 5 ++--- server/src/services/album.service.ts | 4 ++-- server/src/services/notification.service.spec.ts | 10 +++++----- server/test/fixtures/album.stub.ts | 12 ------------ 6 files changed, 15 insertions(+), 34 deletions(-) delete mode 100644 server/src/entities/album-user.entity.ts diff --git a/server/src/database.ts b/server/src/database.ts index 45e7cad490..9ab89b96a5 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,4 +1,4 @@ -import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; +import { AlbumUserRole, AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; export type AuthUser = { @@ -10,6 +10,11 @@ export type AuthUser = { quotaSizeInBytes: number | null; }; +export type AlbumUser = { + user: User; + role: AlbumUserRole; +}; + export type Library = { id: string; ownerId: string; diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts deleted file mode 100644 index b27c68dc74..0000000000 --- a/server/src/entities/album-user.entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { User } from 'src/database'; -import { AlbumEntity } from 'src/entities/album.entity'; -import { AlbumUserRole } from 'src/enum'; - -export class AlbumUserEntity { - albumId!: string; - userId!: string; - album!: AlbumEntity; - user!: User; - role!: AlbumUserRole; -} diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 42f5b5fa02..eb20c1afdd 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -1,5 +1,4 @@ -import { User } from 'src/database'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; +import { AlbumUser, User } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { AssetOrder } from 'src/enum'; @@ -16,7 +15,7 @@ export class AlbumEntity { deletedAt!: Date | null; albumThumbnailAsset!: AssetEntity | null; albumThumbnailAssetId!: string | null; - albumUsers!: AlbumUserEntity[]; + albumUsers!: AlbumUser[]; assets!: AssetEntity[]; sharedLinks!: SharedLinkEntity[]; isActivityEnabled!: boolean; diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index cbe81f1c0d..eac000005b 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -7,13 +7,13 @@ import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, + UpdateAlbumUserDto, mapAlbum, mapAlbumWithAssets, mapAlbumWithoutAssets, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; @@ -247,7 +247,7 @@ export class AlbumService extends BaseService { await this.albumUserRepository.delete({ albumsId: id, usersId: userId }); } - async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { + async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise { await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role }); } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 823f1614ea..bd6c6e098a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,7 +1,7 @@ import { plainToInstance } from 'class-transformer'; import { defaults, SystemConfig } from 'src/config'; +import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { EmailTemplate } from 'src/repositories/notification.repository'; @@ -503,7 +503,7 @@ describe(NotificationService.name, () => { it('should skip recipient that could not be looked up', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValueOnce(userStub.user1); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -516,7 +516,7 @@ describe(NotificationService.name, () => { it('should skip recipient with disabled email notifications', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -537,7 +537,7 @@ describe(NotificationService.name, () => { it('should skip recipient with disabled email notifications for the album update event', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -558,7 +558,7 @@ describe(NotificationService.name, () => { it('should send email', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue(userStub.user1); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 3d2899d3c6..5a1c141512 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -38,10 +38,7 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user1.id, - albumId: 'album-2', }, ], isActivityEnabled: true, @@ -63,17 +60,11 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user1.id, - albumId: 'album-3', }, { user: userStub.user2, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user2.id, - albumId: 'album-3', }, ], isActivityEnabled: true, @@ -95,10 +86,7 @@ export const albumStub = { albumUsers: [ { user: userStub.admin, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.admin.id, - albumId: 'album-3', }, ], isActivityEnabled: true, From 7a1e8ce6d86d9c6923224ed225e92b51aff52005 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:36:29 +0200 Subject: [PATCH 04/22] chore: remove exif entity (#17499) --- server/src/database.ts | 4 +++ server/src/dtos/exif.dto.ts | 6 ++-- server/src/entities/asset.entity.ts | 9 ++--- server/src/entities/exif.entity.ts | 36 -------------------- server/src/repositories/media.repository.ts | 4 +-- server/src/services/asset.service.ts | 2 +- server/src/services/media.service.spec.ts | 22 ++++++------ server/src/services/metadata.service.spec.ts | 4 +-- server/test/fixtures/asset.stub.ts | 32 ++++++++--------- server/test/fixtures/shared-link.stub.ts | 1 - 10 files changed, 44 insertions(+), 76 deletions(-) delete mode 100644 server/src/entities/exif.entity.ts diff --git a/server/src/database.ts b/server/src/database.ts index 9ab89b96a5..e88705711e 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,3 +1,5 @@ +import { Selectable } from 'kysely'; +import { Exif as DatabaseExif } from 'src/db'; import { AlbumUserRole, AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; @@ -189,6 +191,8 @@ export type Session = { deviceType: string; }; +export type Exif = Omit, 'updatedAt' | 'updateId'>; + const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; export const columns = { diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 079891ae56..9fa61d93c8 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ExifEntity } from 'src/entities/exif.entity'; +import { Exif } from 'src/database'; export class ExifResponseDto { make?: string | null = null; @@ -28,7 +28,7 @@ export class ExifResponseDto { rating?: number | null = null; } -export function mapExif(entity: ExifEntity): ExifResponseDto { +export function mapExif(entity: Exif): ExifResponseDto { return { make: entity.make, model: entity.model, @@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { }; } -export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { +export function mapSanitizedExif(entity: Exif): ExifResponseDto { return { fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index def9a92ccd..55ad75d5c2 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,12 +1,11 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { Tag, User } from 'src/database'; +import { Exif, Tag, User } from 'src/database'; import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; @@ -47,7 +46,7 @@ export class AssetEntity { livePhotoVideoId!: string | null; originalFileName!: string; sidecarPath!: string | null; - exifInfo?: ExifEntity; + exifInfo?: Exif; tags?: Tag[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; @@ -65,7 +64,9 @@ export type AssetEntityPlaceholder = AssetEntity & { }; export function withExif(qb: SelectQueryBuilder) { - return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); + return qb + .leftJoin('exif', 'assets.id', 'exif.assetId') + .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo().as('exifInfo')); } export function withExifInner(qb: SelectQueryBuilder) { diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts deleted file mode 100644 index 75064b7917..0000000000 --- a/server/src/entities/exif.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export class ExifEntity { - asset?: AssetEntity; - assetId!: string; - updatedAt?: Date; - updateId?: string; - description!: string; // or caption - exifImageWidth!: number | null; - exifImageHeight!: number | null; - fileSizeInByte!: number | null; - orientation!: string | null; - dateTimeOriginal!: Date | null; - modifyDate!: Date | null; - timeZone!: string | null; - latitude!: number | null; - longitude!: number | null; - projectionType!: string | null; - city!: string | null; - livePhotoCID!: string | null; - autoStackId!: string | null; - state!: string | null; - country!: string | null; - make!: string | null; - model!: string | null; - lensModel!: string | null; - fNumber!: number | null; - focalLength!: number | null; - iso!: number | null; - exposureTime!: string | null; - profileDescription!: string | null; - colorspace!: string | null; - bitsPerSample!: number | null; - rating!: number | null; - fps?: number | null; -} diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d9cac0b018..1e41dd6bb2 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -6,7 +6,7 @@ import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; -import { ExifEntity } from 'src/entities/exif.entity'; +import { Exif } from 'src/database'; import { Colorspace, LogLevel } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { @@ -66,7 +66,7 @@ export class MediaRepository { return true; } - async writeExif(tags: Partial, output: string): Promise { + async writeExif(tags: Partial, output: string): Promise { try { const tagsToWrite: WriteTags = { ExifImageWidth: tags.exifImageWidth, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d05bb023f2..51e54adf5b 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -43,7 +43,7 @@ export class AssetService extends BaseService { yearsAgo, // TODO move this to clients title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), + assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })), }; }); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index a754fc47d0..c55a277fae 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,8 +1,8 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; +import { Exif } from 'src/database'; import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { AssetFileType, AssetPathType, @@ -319,7 +319,7 @@ describe(MediaService.name, () => { it('should generate P3 thumbnails for a wide gamut image', async () => { mocks.asset.getById.mockResolvedValue({ ...assetStub.image, - exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, }); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); @@ -2608,47 +2608,47 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { - const asset = { ...assetStub.image, exifInfo: {} as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: {} as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return false for non-srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 9947d803a7..06337cdd43 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; +import { Exif } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; @@ -1190,7 +1190,7 @@ describe(MetadataService.name, () => { mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif, }, ]); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index afdbaad8dd..669cf1b848 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,6 @@ +import { Exif } from 'src/database'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { StorageAsset } from 'src/types'; @@ -128,7 +128,7 @@ export const assetStub = { isExternal: false, exifInfo: { fileSizeInByte: 123_000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -202,7 +202,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 1000, exifImageWidth: 1000, - } as ExifEntity, + } as Exif, stackId: 'stack-1', stack: stackStub('stack-1', [ { id: 'primary-asset-id' } as AssetEntity, @@ -247,7 +247,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -285,7 +285,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, status: AssetStatus.TRASHED, @@ -326,7 +326,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: true, }), @@ -364,7 +364,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -402,7 +402,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -439,7 +439,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -475,7 +475,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -514,7 +514,7 @@ export const assetStub = { fileSizeInByte: 100_000, exifImageHeight: 2160, exifImageWidth: 3840, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -605,7 +605,7 @@ export const assetStub = { city: 'test-city', state: 'test-state', country: 'test-country', - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -710,7 +710,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 100_000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -749,7 +749,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -788,7 +788,7 @@ export const assetStub = { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -827,7 +827,7 @@ export const assetStub = { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 4d2c509359..be69147e7a 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -232,7 +232,6 @@ export const sharedLinkStub = { iso: 100, exposureTime: '1/16', fps: 100, - asset: null as any, profileDescription: 'sRGB', bitsPerSample: 8, colorspace: 'sRGB', From 7df2c9c905498ce4b31567d6f4b5688cb0e6be1b Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 10 Apr 2025 11:43:35 -0500 Subject: [PATCH 05/22] fix: patch-package install in docker build and better postgres patch (#17523) * always patch package when running npm i, install immich CLI outside of directory so post install doesnt run * handles case where query is an object and defined but origin is not. * move patch-package from a dev dependency to a normal dependency. Also copy the patches folder for the docker build to use and patch with * fix Dockerfile * use query.reject instead of throw for queryError * package-lock to reflect the dev dependency change * dont throw the error, just provide an empty string for query.origin if it does not exist * remove npm link and demote patch-package back to a dev dependency * modify patch to add defensive check to catch queries that will fail to parse and reject --- server/Dockerfile | 3 ++- server/package.json | 2 +- server/patches/postgres+3.4.5.patch | 33 ++++++++++++++++++----------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index d7126d12c6..8b611fd42d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -4,6 +4,7 @@ FROM ghcr.io/immich-app/base-server-dev:202503251114@sha256:10e8973e8603c5729436 RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ +COPY server/patches ./patches RUN npm ci && \ # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # they're marked as optional dependencies, so we need to copy them manually after pruning @@ -56,7 +57,7 @@ COPY server/resources resources COPY server/package.json server/package-lock.json ./ COPY server/start*.sh ./ COPY "docker/scripts/get-cpus.sh" ./ -RUN npm link && npm install -g @immich/cli && npm cache clean --force +RUN npm install -g @immich/cli && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE ENV PATH="${PATH}:/usr/src/app/bin" diff --git a/server/package.json b/server/package.json index 257258234c..f96f3a69a0 100644 --- a/server/package.json +++ b/server/package.json @@ -33,7 +33,7 @@ "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", "email:dev": "email dev -p 3050 --dir src/emails", - "postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true" + "postinstall": "patch-package" }, "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/patches/postgres+3.4.5.patch b/server/patches/postgres+3.4.5.patch index d879416978..019ef9df78 100644 --- a/server/patches/postgres+3.4.5.patch +++ b/server/patches/postgres+3.4.5.patch @@ -1,39 +1,48 @@ diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js -index ee8b1e6..d03b9dd 100644 +index ee8b1e6..acf4566 100644 --- a/node_modules/postgres/cf/src/connection.js +++ b/node_modules/postgres/cf/src/connection.js -@@ -387,6 +387,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js -index f7f58d1..8a37571 100644 +index f7f58d1..b7f2d65 100644 --- a/node_modules/postgres/cjs/src/connection.js +++ b/node_modules/postgres/cjs/src/connection.js -@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js -index 97cc97e..58f5298 100644 +index 97cc97e..26f508e 100644 --- a/node_modules/postgres/src/connection.js +++ b/node_modules/postgres/src/connection.js -@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, From 4412680679dacf969dcb5c6dd9c92ae53af71c3a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 10 Apr 2025 12:44:47 -0400 Subject: [PATCH 06/22] refactor: remove unused shared users list (#17526) --- server/src/dtos/album.dto.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 14db0ab1e8..c9934ec909 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -143,13 +143,11 @@ export class AlbumResponseDto { } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { - const sharedUsers: UserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = []; if (entity.albumUsers) { for (const albumUser of entity.albumUsers) { const user = mapUser(albumUser.user); - sharedUsers.push(user); albumUsers.push({ user, role: albumUser.role, @@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt const assets = entity.assets || []; const hasSharedLink = entity.sharedLinks?.length > 0; - const hasSharedUser = sharedUsers.length > 0; + const hasSharedUser = albumUsers.length > 0; let startDate = assets.at(0)?.localDateTime; let endDate = assets.at(-1)?.localDateTime; From 6d3f3d86161b93ad513032a6b276f378aa887f51 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Apr 2025 11:48:21 -0500 Subject: [PATCH 07/22] refactor: convert download manager into a state class (#17491) * fix(web): download progress bar not functioning * remove unused method --- .../asset-viewer/download-panel.svelte | 10 ++-- web/src/lib/stores/download-store.svelte.ts | 51 +++++++++++++++++++ web/src/lib/stores/download.ts | 48 ----------------- web/src/lib/utils/asset-utils.ts | 2 +- web/src/routes/admin/repair/+page.svelte | 2 +- .../routes/admin/system-settings/+page.svelte | 2 +- 6 files changed, 59 insertions(+), 56 deletions(-) create mode 100644 web/src/lib/stores/download-store.svelte.ts delete mode 100644 web/src/lib/stores/download.ts diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte index 17f5e7e6a8..80a14a5ac3 100644 --- a/web/src/lib/components/asset-viewer/download-panel.svelte +++ b/web/src/lib/components/asset-viewer/download-panel.svelte @@ -1,5 +1,5 @@ -{#if $isDownloading} +{#if downloadStore.isDownloading}

{$t('downloading').toUpperCase()}

- {#each Object.keys($downloadAssets) as downloadKey (downloadKey)} - {@const download = $downloadAssets[downloadKey]} + {#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)} + {@const download = downloadStore.assets[downloadKey]}
@@ -31,7 +31,7 @@ {/if}
-
+

diff --git a/web/src/lib/stores/download-store.svelte.ts b/web/src/lib/stores/download-store.svelte.ts new file mode 100644 index 0000000000..8c03671e73 --- /dev/null +++ b/web/src/lib/stores/download-store.svelte.ts @@ -0,0 +1,51 @@ +export interface DownloadProgress { + progress: number; + total: number; + percentage: number; + abort: AbortController | null; +} + +class DownloadStore { + assets = $state>({}); + + isDownloading = $derived(Object.keys(this.assets).length > 0); + + #update(key: string, value: Partial | null) { + if (value === null) { + delete this.assets[key]; + return; + } + + if (!this.assets[key]) { + this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null }; + } + + const item = this.assets[key]; + Object.assign(item, value); + item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); + } + + add(key: string, total: number, abort?: AbortController) { + this.#update(key, { total, abort }); + } + + clear(key: string) { + this.#update(key, null); + } + + update(key: string, progress: number, total?: number) { + const download: Partial = { progress }; + if (total !== undefined) { + download.total = total; + } + this.#update(key, download); + } +} + +export const downloadStore = new DownloadStore(); + +export const downloadManager = { + add: (key: string, total: number, abort?: AbortController) => downloadStore.add(key, total, abort), + clear: (key: string) => downloadStore.clear(key), + update: (key: string, progress: number, total?: number) => downloadStore.update(key, progress, total), +}; diff --git a/web/src/lib/stores/download.ts b/web/src/lib/stores/download.ts deleted file mode 100644 index fc450d95ef..0000000000 --- a/web/src/lib/stores/download.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { derived, writable } from 'svelte/store'; - -export interface DownloadProgress { - progress: number; - total: number; - percentage: number; - abort: AbortController | null; -} - -export const downloadAssets = writable>({}); - -export const isDownloading = derived(downloadAssets, ($downloadAssets) => { - return Object.keys($downloadAssets).length > 0; -}); - -const update = (key: string, value: Partial | null) => { - downloadAssets.update((state) => { - const newState = { ...state }; - - if (value === null) { - delete newState[key]; - return newState; - } - - if (!newState[key]) { - newState[key] = { progress: 0, total: 0, percentage: 0, abort: null }; - } - - const item = newState[key]; - Object.assign(item, value); - item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); - newState[key] = { ...item }; - - return newState; - }); -}; - -export const downloadManager = { - add: (key: string, total: number, abort?: AbortController) => update(key, { total, abort }), - clear: (key: string) => update(key, null), - update: (key: string, progress: number, total?: number) => { - const download: Partial = { progress }; - if (total !== undefined) { - download.total = total; - } - update(key, download); - }, -}; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index c3b23e1e93..bd3cb416b5 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -5,7 +5,7 @@ import { NotificationType, notificationController } from '$lib/components/shared import { AppRoute } from '$lib/constants'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; -import { downloadManager } from '$lib/stores/download'; +import { downloadManager } from '$lib/stores/download-store.svelte'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte index 17896d2e51..635a140452 100644 --- a/web/src/routes/admin/repair/+page.svelte +++ b/web/src/routes/admin/repair/+page.svelte @@ -7,7 +7,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; - import { downloadManager } from '$lib/stores/download'; + import { downloadManager } from '$lib/stores/download-store.svelte'; import { locale } from '$lib/stores/preferences.store'; import { copyToClipboard } from '$lib/utils'; import { downloadBlob } from '$lib/utils/asset-utils'; diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 6fe4275303..6512461ee9 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -22,7 +22,7 @@ import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import { QueryParameter } from '$lib/constants'; - import { downloadManager } from '$lib/stores/download'; + import { downloadManager } from '$lib/stores/download-store.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; import { copyToClipboard } from '$lib/utils'; import { downloadBlob } from '$lib/utils/asset-utils'; From e3995fb5f4171f7ea466e25453a7ae602b42fb66 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Thu, 10 Apr 2025 13:00:30 -0400 Subject: [PATCH 08/22] fix(web): increase sidebar breakpoint (#17436) --- .../layouts/user-page-layout.svelte | 2 +- .../navigation-bar/navigation-bar.svelte | 46 +++++------ .../side-bar/purchase-info.svelte | 4 +- .../side-bar/side-bar-section.spec.ts | 80 +++++++++++++++++++ .../side-bar/side-bar-section.svelte | 35 ++++---- web/src/lib/stores/mobile-device.svelte.ts | 4 + web/src/lib/stores/side-bar.svelte.ts | 1 - web/src/lib/stores/sidebar.svelte.ts | 21 +++++ web/tailwind.config.js | 1 + 9 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts delete mode 100644 web/src/lib/stores/side-bar.svelte.ts create mode 100644 web/src/lib/stores/sidebar.svelte.ts diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index eb4f286d96..df12e59640 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -51,7 +51,7 @@

{#if sidebar}{@render sidebar()}{:else if admin} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 161407fde4..a6a72e842d 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -23,7 +23,7 @@ import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; - import { isSidebarOpen } from '$lib/stores/side-bar.svelte'; + import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; interface Props { @@ -62,32 +62,30 @@ >
-
- { - isSidebarOpen.value = !isSidebarOpen.value; - }} - onmousedown={(event: MouseEvent) => { - if (isSidebarOpen.value) { - // stops event from reaching the default handler when clicking outside of the sidebar - event.stopPropagation(); - } - }} - class="md:hidden" - /> -
+ { + sidebarStore.toggle(); + }} + onmousedown={(event: MouseEvent) => { + if (sidebarStore.isOpen) { + // stops event from reaching the default handler when clicking outside of the sidebar + event.stopPropagation(); + } + }} + class="sidebar:hidden" + /> - +
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index 47e46c59b5..67d3eaf523 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -110,7 +110,7 @@
@@ -123,7 +123,7 @@ {#if showMessage}