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, - }), + }, };