From 2d7c333c8ca5ac7415c8900f3f88e942406cd889 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 12 Feb 2025 15:23:08 -0500 Subject: [PATCH] refactor(server): narrow auth types (#16066) --- server/src/controllers/user.controller.ts | 6 +- server/src/database.ts | 50 ++++++++++++++ server/src/db.d.ts | 26 ++++---- server/src/dtos/auth.dto.ts | 10 ++- server/src/dtos/user.dto.ts | 2 +- server/src/entities/user-metadata.entity.ts | 9 ++- server/src/queries/api.key.repository.sql | 33 +++++----- server/src/queries/session.repository.sql | 48 +++++--------- server/src/queries/shared.link.repository.sql | 24 ++++--- server/src/repositories/api-key.repository.ts | 34 +++------- server/src/repositories/session.repository.ts | 15 ++++- .../repositories/shared-link.repository.ts | 34 +++------- server/src/repositories/user.repository.ts | 12 +++- server/src/services/auth.service.ts | 24 +++---- server/src/services/download.service.ts | 3 +- server/src/services/notification.service.ts | 4 +- server/src/services/user-admin.service.ts | 17 +++-- server/src/services/user.service.spec.ts | 4 +- server/src/services/user.service.ts | 37 +++++++---- server/src/types.ts | 9 --- server/src/utils/access.ts | 4 +- server/src/utils/preferences.ts | 16 ++--- server/test/fixtures/auth.stub.ts | 66 ++++++++----------- server/test/fixtures/user.stub.ts | 16 ++++- .../test/repositories/user.repository.mock.ts | 1 + 25 files changed, 265 insertions(+), 239 deletions(-) diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 9dbaa00d81..f1bdf160d3 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -44,7 +44,7 @@ export class UserController { @Get('me') @Authenticated() - getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto { + getMyUser(@Auth() auth: AuthDto): Promise { return this.service.getMe(auth); } @@ -56,7 +56,7 @@ export class UserController { @Get('me/preferences') @Authenticated() - getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { + getMyPreferences(@Auth() auth: AuthDto): Promise { return this.service.getMyPreferences(auth); } @@ -71,7 +71,7 @@ export class UserController { @Get('me/license') @Authenticated() - getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto { + getUserLicense(@Auth() auth: AuthDto): Promise { return this.service.getLicense(auth); } diff --git a/server/src/database.ts b/server/src/database.ts index fce9ede561..4fcab0fd6d 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,3 +1,53 @@ +import { Permission } from 'src/enum'; + +export type AuthUser = { + id: string; + isAdmin: boolean; + name: string; + email: string; + quotaUsageInBytes: number; + quotaSizeInBytes: number | null; +}; + +export type AuthApiKey = { + id: string; + permissions: Permission[]; +}; + +export type AuthSharedLink = { + id: string; + expiresAt: Date | null; + userId: string; + showExif: boolean; + allowUpload: boolean; + allowDownload: boolean; + password: string | null; +}; + +export type AuthSession = { + id: string; +}; + export const columns = { + authUser: [ + 'users.id', + 'users.name', + 'users.email', + 'users.isAdmin', + 'users.quotaUsageInBytes', + 'users.quotaSizeInBytes', + ], + authApiKey: ['api_keys.id', 'api_keys.permissions'], + authSession: ['sessions.id', 'sessions.updatedAt'], + authSharedLink: [ + 'shared_links.id', + 'shared_links.userId', + 'shared_links.expiresAt', + 'shared_links.showExif', + 'shared_links.allowUpload', + 'shared_links.allowDownload', + 'shared_links.password', + ], userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'], + apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], } as const; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 2bffe2ba5f..648ccf4bcf 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -3,23 +3,19 @@ * Please do not edit it manually. */ -import type { ColumnType } from "kysely"; +import type { ColumnType } from 'kysely'; +import { Permission } from 'src/enum'; -export type ArrayType = ArrayTypeImpl extends (infer U)[] - ? U[] - : ArrayTypeImpl; +export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; -export type ArrayTypeImpl = T extends ColumnType - ? ColumnType - : T[]; +export type ArrayTypeImpl = T extends ColumnType ? ColumnType : T[]; -export type AssetsStatusEnum = "active" | "deleted" | "trashed"; +export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed'; -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = + T extends ColumnType ? ColumnType : ColumnType; -export type Int8 = ColumnType; +export type Int8 = ColumnType; export type Json = JsonValue; @@ -33,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = "exif" | "machine-learning"; +export type Sourcetype = 'exif' | 'machine-learning'; export type Timestamp = ColumnType; @@ -81,7 +77,7 @@ export interface ApiKeys { id: Generated; key: string; name: string; - permissions: string[]; + permissions: Permission[]; updatedAt: Generated; userId: string; } @@ -444,6 +440,6 @@ export interface DB { typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; - "vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; + 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; version_history: VersionHistory; } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index d6b73f584a..334b7a49b5 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,11 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { SessionEntity } from 'src/entities/session.entity'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; import { UserEntity } from 'src/entities/user.entity'; import { ImmichCookie } from 'src/enum'; -import { AuthApiKey } from 'src/types'; import { toEmail } from 'src/validation'; export type CookieResponse = { @@ -14,11 +12,11 @@ export type CookieResponse = { }; export class AuthDto { - user!: UserEntity; + user!: AuthUser; apiKey?: AuthApiKey; - sharedLink?: SharedLinkEntity; - session?: SessionEntity; + sharedLink?: AuthSharedLink; + session?: AuthSession; } export class LoginCredentialDto { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 593a7934bc..a169784ebb 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -47,7 +47,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => { email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity).avatar.color, + avatarColor: getPreferences(entity.email, entity.metadata || []).avatar.color, profileChangedAt: entity.profileChangedAt, }; }; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 65c187883a..8282443e0e 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -4,13 +4,18 @@ import { DeepPartial } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +export type UserMetadataItem = { + key: T; + value: UserMetadata[T]; +}; + @Entity('user_metadata') -export class UserMetadataEntity { +export class UserMetadataEntity implements UserMetadataItem { @PrimaryColumn({ type: 'uuid' }) userId!: string; @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - user!: UserEntity; + user?: UserEntity; @PrimaryColumn({ type: 'varchar' }) key!: T; diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index e1ed8a3dd6..35fd5d2821 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -3,29 +3,28 @@ -- ApiKeyRepository.getKey select "api_keys"."id", - "api_keys"."key", - "api_keys"."userId", "api_keys"."permissions", - to_json("user") as "user" -from - "api_keys" - inner join lateral ( + ( select - "users".*, + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "api_keys"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "api_keys"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "api_keys" where "api_keys"."key" = $1 diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b928195e72..3d115615fd 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -10,41 +10,29 @@ where -- SessionRepository.getByToken select - "sessions".*, - to_json("user") as "user" -from - "sessions" - inner join lateral ( + "sessions"."id", + "sessions"."updatedAt", + ( select - "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", - "name", - "quotaSizeInBytes", - "quotaUsageInBytes", - "status", - "profileChangedAt", + to_json(obj) + from ( select - array_agg("user_metadata") as "metadata" + "users"."id", + "users"."name", + "users"."email", + "users"."isAdmin", + "users"."quotaUsageInBytes", + "users"."quotaSizeInBytes" from - "user_metadata" + "users" where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "sessions"."userId" - and "users"."deletedAt" is null - ) as "user" on true + "users"."id" = "sessions"."userId" + and "users"."deletedAt" is null + ) as obj + ) as "user" +from + "sessions" where "sessions"."token" = $1 diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index e1f6af3383..641996e2f4 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -153,12 +153,19 @@ where "shared_links"."type" = $2 or "album"."id" is not null ) + and "shared_links"."albumId" = $3 order by "shared_links"."createdAt" desc -- SharedLinkRepository.getByKey select - "shared_links".*, + "shared_links"."id", + "shared_links"."userId", + "shared_links"."expiresAt", + "shared_links"."showExif", + "shared_links"."allowUpload", + "shared_links"."allowDownload", + "shared_links"."password", ( select to_json(obj) @@ -166,20 +173,11 @@ select ( select "users"."id", - "users"."email", - "users"."createdAt", - "users"."profileImagePath", - "users"."isAdmin", - "users"."shouldChangePassword", - "users"."deletedAt", - "users"."oauthId", - "users"."updatedAt", - "users"."storageLabel", "users"."name", - "users"."quotaSizeInBytes", + "users"."email", + "users"."isAdmin", "users"."quotaUsageInBytes", - "users"."status", - "users"."profileChangedAt" + "users"."quotaSizeInBytes" from "users" where diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index 5422ad569e..4ed463365b 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { ApiKeys, DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { asUuid } from 'src/utils/database'; -const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const; - @Injectable() export class ApiKeyRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -33,29 +33,15 @@ export class ApiKeyRepository { getKey(hashedToken: string) { return this.db .selectFrom('api_keys') - .innerJoinLateral( - (eb) => + .select((eb) => [ + ...columns.authApiKey, + jsonObjectFrom( eb .selectFrom('users') - .selectAll('users') - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) + .select(columns.authUser) .whereRef('users.id', '=', 'api_keys.userId') - .where('users.deletedAt', 'is', null) - .as('user'), - (join) => join.onTrue(), - ) - .select((eb) => [ - 'api_keys.id', - 'api_keys.key', - 'api_keys.userId', - 'api_keys.permissions', - eb.fn.toJson('user').as('user'), + .where('users.deletedAt', 'is', null), + ).as('user'), ]) .where('api_keys.key', '=', hashedToken) .executeTakeFirst(); @@ -65,7 +51,7 @@ export class ApiKeyRepository { getById(userId: string, id: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('id', '=', asUuid(id)) .where('userId', '=', userId) .executeTakeFirst(); @@ -75,7 +61,7 @@ export class ApiKeyRepository { getByUserId(userId: string) { return this.db .selectFrom('api_keys') - .select(columns) + .select(columns.apiKey) .where('userId', '=', userId) .orderBy('createdAt', 'desc') .execute(); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 3e490bdc84..85ea5f890e 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { withUser } from 'src/entities/session.entity'; @@ -25,9 +27,16 @@ export class SessionRepository { getByToken(token: string) { return this.db .selectFrom('sessions') - .innerJoinLateral(withUser, (join) => join.onTrue()) - .selectAll('sessions') - .select((eb) => eb.fn.toJson('user').as('user')) + .select((eb) => [ + ...columns.authSession, + jsonObjectFrom( + eb + .selectFrom('users') + .select(columns.authUser) + .whereRef('users.id', '=', 'sessions.userId') + .where('users.deletedAt', 'is', null), + ).as('user'), + ]) .where('sessions.token', '=', token) .executeTakeFirst(); } diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 16dc48836a..52b5b7a2fe 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -3,6 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DB, SharedLinks } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; @@ -96,7 +97,7 @@ export class SharedLinkRepository { .executeTakeFirst() as Promise; } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] }) getAll({ userId, albumId }: SharedLinkSearchOptions): Promise { return this.db .selectFrom('shared_links') @@ -160,39 +161,20 @@ export class SharedLinkRepository { } @GenerateSql({ params: [DummyValue.BUFFER] }) - async getByKey(key: Buffer): Promise { + async getByKey(key: Buffer) { return this.db .selectFrom('shared_links') - .selectAll('shared_links') .where('shared_links.key', '=', key) .leftJoin('albums', 'albums.id', 'shared_links.albumId') .where('albums.deletedAt', 'is', null) - .select((eb) => + .select((eb) => [ + ...columns.authSharedLink, jsonObjectFrom( - eb - .selectFrom('users') - .select([ - 'users.id', - 'users.email', - 'users.createdAt', - 'users.profileImagePath', - 'users.isAdmin', - 'users.shouldChangePassword', - 'users.deletedAt', - 'users.oauthId', - 'users.updatedAt', - 'users.storageLabel', - 'users.name', - 'users.quotaSizeInBytes', - 'users.quotaUsageInBytes', - 'users.status', - 'users.profileChangedAt', - ]) - .whereRef('users.id', '=', 'shared_links.userId'), + eb.selectFrom('users').select(columns.authUser).whereRef('users.id', '=', 'shared_links.userId'), ).as('user'), - ) + ]) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)])) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } async create(entity: Insertable & { assetIds?: string[] }): Promise { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 22a9ecad5c..fccd127378 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -3,7 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserMetadata } from 'src/entities/user-metadata.entity'; +import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { UserStatus } from 'src/enum'; import { asUuid } from 'src/utils/database'; @@ -64,6 +64,14 @@ export class UserRepository { .executeTakeFirst() as Promise; } + getMetadata(userId: string) { + return this.db + .selectFrom('user_metadata') + .select(['key', 'value']) + .where('user_metadata.userId', '=', userId) + .execute() as Promise; + } + @GenerateSql() getAdmin(): Promise { return this.db @@ -263,7 +271,7 @@ export class UserRepository { eb .selectFrom('assets') .leftJoin('exif', 'exif.assetId', 'assets.id') - .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) + .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage')) .where('assets.libraryId', 'is', null) .where('assets.ownerId', '=', eb.ref('users.id')), updatedAt: new Date(), diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f4c6c6249e..35d48cf57e 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -17,12 +17,10 @@ import { mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { SessionEntity } from 'src/entities/session.entity'; 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'; -import { AuthApiKey } from 'src/types'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -298,11 +296,11 @@ export class AuthService extends BaseService { const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const sharedLink = await this.sharedLinkRepository.getByKey(bytes); - if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { - const user = sharedLink.user; - if (user) { - return { user, sharedLink }; - } + if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { + return { + user: sharedLink.user, + sharedLink, + }; } throw new UnauthorizedException('Invalid share key'); } @@ -310,10 +308,10 @@ export class AuthService extends BaseService { private async validateApiKey(key: string): Promise { const hashedKey = this.cryptoRepository.hashSha256(key); const apiKey = await this.keyRepository.getKey(hashedKey); - if (apiKey) { + if (apiKey?.user) { return { - user: apiKey.user as unknown as UserEntity, - apiKey: apiKey as unknown as AuthApiKey, + user: apiKey.user, + apiKey, }; } @@ -330,7 +328,6 @@ export class AuthService extends BaseService { private async validateSession(tokenValue: string): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); - if (session?.user) { const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); @@ -339,7 +336,10 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } - return { user: session.user as unknown as UserEntity, session: session as unknown as SessionEntity }; + return { + user: session.user, + session, + }; } throw new UnauthorizedException('Invalid user token'); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index dd2430778a..8b18bd0a07 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -19,7 +19,8 @@ export class DownloadService extends BaseService { const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; - const preferences = getPreferences(auth.user); + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); const assetPagination = await this.getDownloadAssets(auth, dto); for await (const assets of assetPagination) { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 003b320997..bc6f6b8c2f 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -276,7 +276,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { emailNotifications } = getPreferences(recipient); + const { emailNotifications } = getPreferences(recipient.email, recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { return JobStatus.SKIPPED; @@ -340,7 +340,7 @@ export class NotificationService extends BaseService { continue; } - const { emailNotifications } = getPreferences(user); + const { emailNotifications } = getPreferences(user.email, user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { continue; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 37c3c1e004..0cba749d36 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -106,21 +106,24 @@ export class UserAdminService extends BaseService { } async getPreferences(auth: AuthDto, id: string): Promise { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = getPreferences(user); + const { email } = await this.findOrFail(id, { withDeleted: true }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); return mapPreferences(preferences); } async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { - const user = await this.findOrFail(id, { withDeleted: false }); - const preferences = mergePreferences(user, dto); + const { email } = await this.findOrFail(id, { withDeleted: false }); + const metadata = await this.userRepository.getMetadata(id); + const preferences = getPreferences(email, metadata); + const newPreferences = mergePreferences(preferences, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial({ email }, newPreferences), }); - return mapPreferences(preferences); + return mapPreferences(newPreferences); } private async findOrFail(id: string, options: UserFindOptions) { diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 06c928da14..b9fa39a8c2 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -77,9 +77,9 @@ describe(UserService.name, () => { }); describe('getMe', () => { - it("should get the auth user's info", () => { + it("should get the auth user's info", async () => { const user = authStub.admin.user; - expect(sut.getMe(authStub.admin)).toMatchObject({ + await expect(sut.getMe(authStub.admin)).resolves.toMatchObject({ id: user.id, email: user.email, }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 3ec6281009..ae6e94031f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -22,16 +22,24 @@ export class UserService extends BaseService { async search(auth: AuthDto): Promise { const config = await this.getConfig({ withCache: false }); - let users: UserEntity[] = [auth.user]; + let users; if (auth.user.isAdmin || config.server.publicUsers) { users = await this.userRepository.getList({ withDeleted: false }); + } else { + const authUser = await this.userRepository.get(auth.user.id, {}); + users = authUser ? [authUser] : []; } return users.map((user) => mapUser(user)); } - getMe(auth: AuthDto): UserAdminResponseDto { - return mapUserAdmin(auth.user); + async getMe(auth: AuthDto): Promise { + const user = await this.userRepository.get(auth.user.id, {}); + if (!user) { + throw new BadRequestException('User not found'); + } + + return mapUserAdmin(user); } async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { @@ -58,20 +66,23 @@ export class UserService extends BaseService { return mapUserAdmin(updatedUser); } - getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { - const preferences = getPreferences(user); + async getMyPreferences(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const preferences = getPreferences(auth.user.email, metadata); return mapPreferences(preferences); } - async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { - const preferences = mergePreferences(user, dto); + async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) { + const metadata = await this.userRepository.getMetadata(auth.user.id); + const current = getPreferences(auth.user.email, metadata); + const updated = mergePreferences(current, dto); - await this.userRepository.upsertMetadata(user.id, { + await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(user, preferences), + value: getPreferencesPartial(auth.user, updated), }); - return mapPreferences(preferences); + return mapPreferences(updated); } async get(id: string): Promise { @@ -120,8 +131,10 @@ export class UserService extends BaseService { }); } - getLicense({ user }: AuthDto): LicenseResponseDto { - const license = user.metadata.find( + async getLicense(auth: AuthDto): Promise { + const metadata = await this.userRepository.getMetadata(auth.user.id); + + const license = metadata.find( (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, ); if (!license) { diff --git a/server/src/types.ts b/server/src/types.ts index 47a6d20797..3a331127e6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,10 +1,8 @@ -import { UserEntity } from 'src/entities/user.entity'; import { DatabaseExtension, ExifOrientation, ImageFormat, JobName, - Permission, QueueName, TranscodeTarget, VideoCodec, @@ -16,13 +14,6 @@ import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; -export type AuthApiKey = { - id: string; - key: string; - user: UserEntity; - permissions: Permission[]; -}; - export type RepositoryInterface = Pick; type IActivityRepository = RepositoryInterface; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index cb91737349..466d8851e6 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,6 +1,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthSharedLink } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { AlbumUserRole, Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; @@ -24,7 +24,7 @@ export type AccessRequest = { ids: Set | string[]; }; -type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set }; type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index ed9b5f2b83..14e61f1919 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,18 +1,13 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataItem, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserMetadataKey } from 'src/enum'; import { DeepPartial } from 'src/types'; import { getKeysDeep } from 'src/utils/misc'; -export const getPreferences = (user: UserEntity) => { - const preferences = getDefaultPreferences(user); - if (!user.metadata) { - return preferences; - } - - const item = user.metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); +export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { + const preferences = getDefaultPreferences({ email }); + const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { _.set(preferences, property, _.get(partial, property)); @@ -40,8 +35,7 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U return partial; }; -export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { - const preferences = getPreferences(user); +export const mergePreferences = (preferences: UserPreferences, dto: UserPreferencesUpdateDto) => { for (const key of getKeysDeep(dto)) { _.set(preferences, key, _.get(dto, key)); } diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2989c0cce1..f894314258 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,25 +1,30 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; -import { UserEntity } from 'src/entities/user.entity'; + +const authUser = { + admin: { + id: 'admin_id', + name: 'admin', + email: 'admin@test.com', + isAdmin: true, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, + user1: { + id: 'user-id', + name: 'User 1', + email: 'immich@test.com', + isAdmin: false, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, +}; export const authStub = { - admin: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - }), + admin: Object.freeze({ user: authUser.admin }), user1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.user1, session: { id: 'token-id', } as SessionEntity, @@ -27,21 +32,18 @@ export const authStub = { user2: Object.freeze({ user: { id: 'user-2', - email: 'user2@immich.app', + name: 'User 2', + email: 'user2@immich.cloud', isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + }, session: { id: 'token-id', } as SessionEntity, }), adminSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: true, @@ -51,12 +53,7 @@ export const authStub = { } as SharedLinkEntity, }), adminSharedLinkNoExif: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', showExif: false, @@ -66,12 +63,7 @@ export const authStub = { } as SharedLinkEntity, }), passwordSharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, + user: authUser.admin, sharedLink: { id: '123', allowUpload: false, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 9553b5344a..9153cfa8f2 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,10 +1,12 @@ import { UserEntity } from 'src/entities/user.entity'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { admin: Object.freeze({ ...authStub.admin.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'admin_password', name: 'admin_name', id: 'admin_id', @@ -23,6 +25,8 @@ export const userStub = { }), user1: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -36,7 +40,6 @@ export const userStub = { assets: [], metadata: [ { - user: authStub.user1.user, userId: authStub.user1.user.id, key: UserMetadataKey.PREFERENCES, value: { avatar: { color: UserAvatarColor.PRIMARY } }, @@ -47,6 +50,9 @@ export const userStub = { }), user2: Object.freeze({ ...authStub.user2.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: null, @@ -63,6 +69,9 @@ export const userStub = { }), storageLabel: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', @@ -79,6 +88,9 @@ export const userStub = { }), profilePath: Object.freeze({ ...authStub.user1.user, + status: UserStatus.ACTIVE, + profileChangedAt: new Date('2021-01-01'), + metadata: [], password: 'immich_password', name: 'immich_name', storageLabel: 'label-1', diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index d7ebee09d8..2dc6b9eec2 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest'; export const newUserRepositoryMock = (): Mocked> => { return { get: vitest.fn(), + getMetadata: vitest.fn().mockResolvedValue([]), getAdmin: vitest.fn(), getByEmail: vitest.fn(), getByStorageLabel: vitest.fn(),