refactor(server): narrow auth types (#16066)

This commit is contained in:
Jason Rasmussen 2025-02-12 15:23:08 -05:00 committed by GitHub
parent 7c821dd205
commit 2d7c333c8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 265 additions and 239 deletions

View File

@ -44,7 +44,7 @@ export class UserController {
@Get('me') @Get('me')
@Authenticated() @Authenticated()
getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto { getMyUser(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.getMe(auth); return this.service.getMe(auth);
} }
@ -56,7 +56,7 @@ export class UserController {
@Get('me/preferences') @Get('me/preferences')
@Authenticated() @Authenticated()
getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto { getMyPreferences(@Auth() auth: AuthDto): Promise<UserPreferencesResponseDto> {
return this.service.getMyPreferences(auth); return this.service.getMyPreferences(auth);
} }
@ -71,7 +71,7 @@ export class UserController {
@Get('me/license') @Get('me/license')
@Authenticated() @Authenticated()
getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto { getUserLicense(@Auth() auth: AuthDto): Promise<LicenseResponseDto> {
return this.service.getLicense(auth); return this.service.getLicense(auth);
} }

View File

@ -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 = { 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'], userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
} as const; } as const;

26
server/src/db.d.ts vendored
View File

@ -3,23 +3,19 @@
* Please do not edit it manually. * Please do not edit it manually.
*/ */
import type { ColumnType } from "kysely"; import type { ColumnType } from 'kysely';
import { Permission } from 'src/enum';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
? U[]
: ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
? ColumnType<S[], I[], U[]>
: T[];
export type AssetsStatusEnum = "active" | "deleted" | "trashed"; export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> =
? ColumnType<S, I | undefined, U> T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>; export type Int8 = ColumnType<number>;
export type Json = JsonValue; export type Json = JsonValue;
@ -33,7 +29,7 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive; export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = "exif" | "machine-learning"; export type Sourcetype = 'exif' | 'machine-learning';
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
@ -81,7 +77,7 @@ export interface ApiKeys {
id: Generated<string>; id: Generated<string>;
key: string; key: string;
name: string; name: string;
permissions: string[]; permissions: Permission[];
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
userId: string; userId: string;
} }
@ -444,6 +440,6 @@ export interface DB {
typeorm_metadata: TypeormMetadata; typeorm_metadata: TypeormMetadata;
user_metadata: UserMetadata; user_metadata: UserMetadata;
users: Users; users: Users;
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
version_history: VersionHistory; version_history: VersionHistory;
} }

View File

@ -1,11 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { SessionEntity } from 'src/entities/session.entity'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { ImmichCookie } from 'src/enum'; import { ImmichCookie } from 'src/enum';
import { AuthApiKey } from 'src/types';
import { toEmail } from 'src/validation'; import { toEmail } from 'src/validation';
export type CookieResponse = { export type CookieResponse = {
@ -14,11 +12,11 @@ export type CookieResponse = {
}; };
export class AuthDto { export class AuthDto {
user!: UserEntity; user!: AuthUser;
apiKey?: AuthApiKey; apiKey?: AuthApiKey;
sharedLink?: SharedLinkEntity; sharedLink?: AuthSharedLink;
session?: SessionEntity; session?: AuthSession;
} }
export class LoginCredentialDto { export class LoginCredentialDto {

View File

@ -47,7 +47,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => {
email: entity.email, email: entity.email,
name: entity.name, name: entity.name,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color, avatarColor: getPreferences(entity.email, entity.metadata || []).avatar.color,
profileChangedAt: entity.profileChangedAt, profileChangedAt: entity.profileChangedAt,
}; };
}; };

View File

@ -4,13 +4,18 @@ import { DeepPartial } from 'src/types';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
@Entity('user_metadata') @Entity('user_metadata')
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> { export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
@PrimaryColumn({ type: 'uuid' }) @PrimaryColumn({ type: 'uuid' })
userId!: string; userId!: string;
@ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user!: UserEntity; user?: UserEntity;
@PrimaryColumn({ type: 'varchar' }) @PrimaryColumn({ type: 'varchar' })
key!: T; key!: T;

View File

@ -3,29 +3,28 @@
-- ApiKeyRepository.getKey -- ApiKeyRepository.getKey
select select
"api_keys"."id", "api_keys"."id",
"api_keys"."key",
"api_keys"."userId",
"api_keys"."permissions", "api_keys"."permissions",
to_json("user") as "user" (
from
"api_keys"
inner join lateral (
select select
"users".*, to_json(obj)
from
( (
select select
array_agg("user_metadata") as "metadata" "users"."id",
"users"."name",
"users"."email",
"users"."isAdmin",
"users"."quotaUsageInBytes",
"users"."quotaSizeInBytes"
from from
"user_metadata" "users"
where where
"users"."id" = "user_metadata"."userId" "users"."id" = "api_keys"."userId"
) as "metadata" and "users"."deletedAt" is null
from ) as obj
"users" ) as "user"
where from
"users"."id" = "api_keys"."userId" "api_keys"
and "users"."deletedAt" is null
) as "user" on true
where where
"api_keys"."key" = $1 "api_keys"."key" = $1

View File

@ -10,41 +10,29 @@ where
-- SessionRepository.getByToken -- SessionRepository.getByToken
select select
"sessions".*, "sessions"."id",
to_json("user") as "user" "sessions"."updatedAt",
from (
"sessions"
inner join lateral (
select select
"id", to_json(obj)
"email", from
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
( (
select select
array_agg("user_metadata") as "metadata" "users"."id",
"users"."name",
"users"."email",
"users"."isAdmin",
"users"."quotaUsageInBytes",
"users"."quotaSizeInBytes"
from from
"user_metadata" "users"
where where
"users"."id" = "user_metadata"."userId" "users"."id" = "sessions"."userId"
) as "metadata" and "users"."deletedAt" is null
from ) as obj
"users" ) as "user"
where from
"users"."id" = "sessions"."userId" "sessions"
and "users"."deletedAt" is null
) as "user" on true
where where
"sessions"."token" = $1 "sessions"."token" = $1

View File

@ -153,12 +153,19 @@ where
"shared_links"."type" = $2 "shared_links"."type" = $2
or "album"."id" is not null or "album"."id" is not null
) )
and "shared_links"."albumId" = $3
order by order by
"shared_links"."createdAt" desc "shared_links"."createdAt" desc
-- SharedLinkRepository.getByKey -- SharedLinkRepository.getByKey
select 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 select
to_json(obj) to_json(obj)
@ -166,20 +173,11 @@ select
( (
select select
"users"."id", "users"."id",
"users"."email",
"users"."createdAt",
"users"."profileImagePath",
"users"."isAdmin",
"users"."shouldChangePassword",
"users"."deletedAt",
"users"."oauthId",
"users"."updatedAt",
"users"."storageLabel",
"users"."name", "users"."name",
"users"."quotaSizeInBytes", "users"."email",
"users"."isAdmin",
"users"."quotaUsageInBytes", "users"."quotaUsageInBytes",
"users"."status", "users"."quotaSizeInBytes"
"users"."profileChangedAt"
from from
"users" "users"
where where

View File

@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { ApiKeys, DB } from 'src/db'; import { ApiKeys, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
const columns = ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'] as const;
@Injectable() @Injectable()
export class ApiKeyRepository { export class ApiKeyRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@ -33,29 +33,15 @@ export class ApiKeyRepository {
getKey(hashedToken: string) { getKey(hashedToken: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.innerJoinLateral( .select((eb) => [
(eb) => ...columns.authApiKey,
jsonObjectFrom(
eb eb
.selectFrom('users') .selectFrom('users')
.selectAll('users') .select(columns.authUser)
.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'),
)
.whereRef('users.id', '=', 'api_keys.userId') .whereRef('users.id', '=', 'api_keys.userId')
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null),
.as('user'), ).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('api_keys.key', '=', hashedToken) .where('api_keys.key', '=', hashedToken)
.executeTakeFirst(); .executeTakeFirst();
@ -65,7 +51,7 @@ export class ApiKeyRepository {
getById(userId: string, id: string) { getById(userId: string, id: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.select(columns) .select(columns.apiKey)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.where('userId', '=', userId) .where('userId', '=', userId)
.executeTakeFirst(); .executeTakeFirst();
@ -75,7 +61,7 @@ export class ApiKeyRepository {
getByUserId(userId: string) { getByUserId(userId: string) {
return this.db return this.db
.selectFrom('api_keys') .selectFrom('api_keys')
.select(columns) .select(columns.apiKey)
.where('userId', '=', userId) .where('userId', '=', userId)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
.execute(); .execute();

View File

@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely'; import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Sessions } from 'src/db'; import { DB, Sessions } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { withUser } from 'src/entities/session.entity'; import { withUser } from 'src/entities/session.entity';
@ -25,9 +27,16 @@ export class SessionRepository {
getByToken(token: string) { getByToken(token: string) {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue()) .select((eb) => [
.selectAll('sessions') ...columns.authSession,
.select((eb) => eb.fn.toJson('user').as('user')) jsonObjectFrom(
eb
.selectFrom('users')
.select(columns.authUser)
.whereRef('users.id', '=', 'sessions.userId')
.where('users.deletedAt', 'is', null),
).as('user'),
])
.where('sessions.token', '=', token) .where('sessions.token', '=', token)
.executeTakeFirst(); .executeTakeFirst();
} }

View File

@ -3,6 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash'; import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, SharedLinks } from 'src/db'; import { DB, SharedLinks } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
@ -96,7 +97,7 @@ export class SharedLinkRepository {
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>; .executeTakeFirst() as Promise<SharedLinkEntity | undefined>;
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> { getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')
@ -160,39 +161,20 @@ export class SharedLinkRepository {
} }
@GenerateSql({ params: [DummyValue.BUFFER] }) @GenerateSql({ params: [DummyValue.BUFFER] })
async getByKey(key: Buffer): Promise<SharedLinkEntity | undefined> { async getByKey(key: Buffer) {
return this.db return this.db
.selectFrom('shared_links') .selectFrom('shared_links')
.selectAll('shared_links')
.where('shared_links.key', '=', key) .where('shared_links.key', '=', key)
.leftJoin('albums', 'albums.id', 'shared_links.albumId') .leftJoin('albums', 'albums.id', 'shared_links.albumId')
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.select((eb) => .select((eb) => [
...columns.authSharedLink,
jsonObjectFrom( jsonObjectFrom(
eb eb.selectFrom('users').select(columns.authUser).whereRef('users.id', '=', 'shared_links.userId'),
.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'),
).as('user'), ).as('user'),
) ])
.where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)])) .where((eb) => eb.or([eb('shared_links.type', '=', SharedLinkType.INDIVIDUAL), eb('albums.id', 'is not', null)]))
.executeTakeFirst() as Promise<SharedLinkEntity | undefined>; .executeTakeFirst();
} }
async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity> { async create(entity: Insertable<SharedLinks> & { assetIds?: string[] }): Promise<SharedLinkEntity> {

View File

@ -3,7 +3,7 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; 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 { UserEntity, withMetadata } from 'src/entities/user.entity';
import { UserStatus } from 'src/enum'; import { UserStatus } from 'src/enum';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
@ -64,6 +64,14 @@ export class UserRepository {
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst() as Promise<UserEntity | undefined>;
} }
getMetadata(userId: string) {
return this.db
.selectFrom('user_metadata')
.select(['key', 'value'])
.where('user_metadata.userId', '=', userId)
.execute() as Promise<UserMetadataItem[]>;
}
@GenerateSql() @GenerateSql()
getAdmin(): Promise<UserEntity | undefined> { getAdmin(): Promise<UserEntity | undefined> {
return this.db return this.db
@ -263,7 +271,7 @@ export class UserRepository {
eb eb
.selectFrom('assets') .selectFrom('assets')
.leftJoin('exif', 'exif.assetId', 'assets.id') .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<number>('exif.fileSizeInByte'), eb.lit(0)).as('usage'))
.where('assets.libraryId', 'is', null) .where('assets.libraryId', 'is', null)
.where('assets.ownerId', '=', eb.ref('users.id')), .where('assets.ownerId', '=', eb.ref('users.id')),
updatedAt: new Date(), updatedAt: new Date(),

View File

@ -17,12 +17,10 @@ import {
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SessionEntity } from 'src/entities/session.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository'; import { OAuthProfile } from 'src/repositories/oauth.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { AuthApiKey } from 'src/types';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; 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 bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
const sharedLink = await this.sharedLinkRepository.getByKey(bytes); const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
if (sharedLink && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) {
const user = sharedLink.user; return {
if (user) { user: sharedLink.user,
return { user, sharedLink }; sharedLink,
} };
} }
throw new UnauthorizedException('Invalid share key'); throw new UnauthorizedException('Invalid share key');
} }
@ -310,10 +308,10 @@ export class AuthService extends BaseService {
private async validateApiKey(key: string): Promise<AuthDto> { private async validateApiKey(key: string): Promise<AuthDto> {
const hashedKey = this.cryptoRepository.hashSha256(key); const hashedKey = this.cryptoRepository.hashSha256(key);
const apiKey = await this.keyRepository.getKey(hashedKey); const apiKey = await this.keyRepository.getKey(hashedKey);
if (apiKey) { if (apiKey?.user) {
return { return {
user: apiKey.user as unknown as UserEntity, user: apiKey.user,
apiKey: apiKey as unknown as AuthApiKey, apiKey,
}; };
} }
@ -330,7 +328,6 @@ export class AuthService extends BaseService {
private async validateSession(tokenValue: string): Promise<AuthDto> { private async validateSession(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
const session = await this.sessionRepository.getByToken(hashedToken); const session = await this.sessionRepository.getByToken(hashedToken);
if (session?.user) { if (session?.user) {
const now = DateTime.now(); const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(session.updatedAt); 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() }); 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'); throw new UnauthorizedException('Invalid user token');

View File

@ -19,7 +19,8 @@ export class DownloadService extends BaseService {
const archives: DownloadArchiveInfo[] = []; const archives: DownloadArchiveInfo[] = [];
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; 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); const assetPagination = await this.getDownloadAssets(auth, dto);
for await (const assets of assetPagination) { for await (const assets of assetPagination) {

View File

@ -276,7 +276,7 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { emailNotifications } = getPreferences(recipient); const { emailNotifications } = getPreferences(recipient.email, recipient.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumInvite) { if (!emailNotifications.enabled || !emailNotifications.albumInvite) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@ -340,7 +340,7 @@ export class NotificationService extends BaseService {
continue; continue;
} }
const { emailNotifications } = getPreferences(user); const { emailNotifications } = getPreferences(user.email, user.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
continue; continue;

View File

@ -106,21 +106,24 @@ export class UserAdminService extends BaseService {
} }
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> { async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false }); const { email } = await this.findOrFail(id, { withDeleted: true });
const preferences = getPreferences(user); const metadata = await this.userRepository.getMetadata(id);
const preferences = getPreferences(email, metadata);
return mapPreferences(preferences); return mapPreferences(preferences);
} }
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
const user = await this.findOrFail(id, { withDeleted: false }); const { email } = await this.findOrFail(id, { withDeleted: false });
const preferences = mergePreferences(user, dto); 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, key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, preferences), value: getPreferencesPartial({ email }, newPreferences),
}); });
return mapPreferences(preferences); return mapPreferences(newPreferences);
} }
private async findOrFail(id: string, options: UserFindOptions) { private async findOrFail(id: string, options: UserFindOptions) {

View File

@ -77,9 +77,9 @@ describe(UserService.name, () => {
}); });
describe('getMe', () => { describe('getMe', () => {
it("should get the auth user's info", () => { it("should get the auth user's info", async () => {
const user = authStub.admin.user; const user = authStub.admin.user;
expect(sut.getMe(authStub.admin)).toMatchObject({ await expect(sut.getMe(authStub.admin)).resolves.toMatchObject({
id: user.id, id: user.id,
email: user.email, email: user.email,
}); });

View File

@ -22,16 +22,24 @@ export class UserService extends BaseService {
async search(auth: AuthDto): Promise<UserResponseDto[]> { async search(auth: AuthDto): Promise<UserResponseDto[]> {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
let users: UserEntity[] = [auth.user]; let users;
if (auth.user.isAdmin || config.server.publicUsers) { if (auth.user.isAdmin || config.server.publicUsers) {
users = await this.userRepository.getList({ withDeleted: false }); 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)); return users.map((user) => mapUser(user));
} }
getMe(auth: AuthDto): UserAdminResponseDto { async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
return mapUserAdmin(auth.user); 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<UserAdminResponseDto> { async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
@ -58,20 +66,23 @@ export class UserService extends BaseService {
return mapUserAdmin(updatedUser); return mapUserAdmin(updatedUser);
} }
getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto { async getMyPreferences(auth: AuthDto): Promise<UserPreferencesResponseDto> {
const preferences = getPreferences(user); const metadata = await this.userRepository.getMetadata(auth.user.id);
const preferences = getPreferences(auth.user.email, metadata);
return mapPreferences(preferences); return mapPreferences(preferences);
} }
async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) { async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) {
const preferences = mergePreferences(user, dto); 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, key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, preferences), value: getPreferencesPartial(auth.user, updated),
}); });
return mapPreferences(preferences); return mapPreferences(updated);
} }
async get(id: string): Promise<UserResponseDto> { async get(id: string): Promise<UserResponseDto> {
@ -120,8 +131,10 @@ export class UserService extends BaseService {
}); });
} }
getLicense({ user }: AuthDto): LicenseResponseDto { async getLicense(auth: AuthDto): Promise<LicenseResponseDto> {
const license = user.metadata.find( const metadata = await this.userRepository.getMetadata(auth.user.id);
const license = metadata.find(
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE, (item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
); );
if (!license) { if (!license) {

View File

@ -1,10 +1,8 @@
import { UserEntity } from 'src/entities/user.entity';
import { import {
DatabaseExtension, DatabaseExtension,
ExifOrientation, ExifOrientation,
ImageFormat, ImageFormat,
JobName, JobName,
Permission,
QueueName, QueueName,
TranscodeTarget, TranscodeTarget,
VideoCodec, VideoCodec,
@ -16,13 +14,6 @@ import { SessionRepository } from 'src/repositories/session.repository';
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
export type AuthApiKey = {
id: string;
key: string;
user: UserEntity;
permissions: Permission[];
};
export type RepositoryInterface<T extends object> = Pick<T, keyof T>; export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
type IActivityRepository = RepositoryInterface<ActivityRepository>; type IActivityRepository = RepositoryInterface<ActivityRepository>;

View File

@ -1,6 +1,6 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { AlbumUserRole, Permission } from 'src/enum'; import { AlbumUserRole, Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set';
@ -24,7 +24,7 @@ export type AccessRequest = {
ids: Set<string> | string[]; ids: Set<string> | string[];
}; };
type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set<string> }; type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set<string> };
type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set<string> }; type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set<string> };
export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { export const requireUploadAccess = (auth: AuthDto | null): AuthDto => {

View File

@ -1,18 +1,13 @@
import _ from 'lodash'; import _ from 'lodash';
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserMetadataItem, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { UserMetadataKey } from 'src/enum'; import { UserMetadataKey } from 'src/enum';
import { DeepPartial } from 'src/types'; import { DeepPartial } from 'src/types';
import { getKeysDeep } from 'src/utils/misc'; import { getKeysDeep } from 'src/utils/misc';
export const getPreferences = (user: UserEntity) => { export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => {
const preferences = getDefaultPreferences(user); const preferences = getDefaultPreferences({ email });
if (!user.metadata) { const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES);
return preferences;
}
const item = user.metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES);
const partial = item?.value || {}; const partial = item?.value || {};
for (const property of getKeysDeep(partial)) { for (const property of getKeysDeep(partial)) {
_.set(preferences, property, _.get(partial, property)); _.set(preferences, property, _.get(partial, property));
@ -40,8 +35,7 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U
return partial; return partial;
}; };
export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => { export const mergePreferences = (preferences: UserPreferences, dto: UserPreferencesUpdateDto) => {
const preferences = getPreferences(user);
for (const key of getKeysDeep(dto)) { for (const key of getKeysDeep(dto)) {
_.set(preferences, key, _.get(dto, key)); _.set(preferences, key, _.get(dto, key));
} }

View File

@ -1,25 +1,30 @@
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionEntity } from 'src/entities/session.entity'; import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.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 = { export const authStub = {
admin: Object.freeze<AuthDto>({ admin: Object.freeze<AuthDto>({ user: authUser.admin }),
user: {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
}),
user1: Object.freeze<AuthDto>({ user1: Object.freeze<AuthDto>({
user: { user: authUser.user1,
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
session: { session: {
id: 'token-id', id: 'token-id',
} as SessionEntity, } as SessionEntity,
@ -27,21 +32,18 @@ export const authStub = {
user2: Object.freeze<AuthDto>({ user2: Object.freeze<AuthDto>({
user: { user: {
id: 'user-2', id: 'user-2',
email: 'user2@immich.app', name: 'User 2',
email: 'user2@immich.cloud',
isAdmin: false, isAdmin: false,
metadata: [] as UserMetadataEntity[], quotaSizeInBytes: null,
} as UserEntity, quotaUsageInBytes: 0,
},
session: { session: {
id: 'token-id', id: 'token-id',
} as SessionEntity, } as SessionEntity,
}), }),
adminSharedLink: Object.freeze<AuthDto>({ adminSharedLink: Object.freeze<AuthDto>({
user: { user: authUser.admin,
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: { sharedLink: {
id: '123', id: '123',
showExif: true, showExif: true,
@ -51,12 +53,7 @@ export const authStub = {
} as SharedLinkEntity, } as SharedLinkEntity,
}), }),
adminSharedLinkNoExif: Object.freeze<AuthDto>({ adminSharedLinkNoExif: Object.freeze<AuthDto>({
user: { user: authUser.admin,
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: { sharedLink: {
id: '123', id: '123',
showExif: false, showExif: false,
@ -66,12 +63,7 @@ export const authStub = {
} as SharedLinkEntity, } as SharedLinkEntity,
}), }),
passwordSharedLink: Object.freeze<AuthDto>({ passwordSharedLink: Object.freeze<AuthDto>({
user: { user: authUser.admin,
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: { sharedLink: {
id: '123', id: '123',
allowUpload: false, allowUpload: false,

View File

@ -1,10 +1,12 @@
import { UserEntity } from 'src/entities/user.entity'; 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'; import { authStub } from 'test/fixtures/auth.stub';
export const userStub = { export const userStub = {
admin: Object.freeze<UserEntity>({ admin: Object.freeze<UserEntity>({
...authStub.admin.user, ...authStub.admin.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
password: 'admin_password', password: 'admin_password',
name: 'admin_name', name: 'admin_name',
id: 'admin_id', id: 'admin_id',
@ -23,6 +25,8 @@ export const userStub = {
}), }),
user1: Object.freeze<UserEntity>({ user1: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
password: 'immich_password', password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: null, storageLabel: null,
@ -36,7 +40,6 @@ export const userStub = {
assets: [], assets: [],
metadata: [ metadata: [
{ {
user: authStub.user1.user,
userId: authStub.user1.user.id, userId: authStub.user1.user.id,
key: UserMetadataKey.PREFERENCES, key: UserMetadataKey.PREFERENCES,
value: { avatar: { color: UserAvatarColor.PRIMARY } }, value: { avatar: { color: UserAvatarColor.PRIMARY } },
@ -47,6 +50,9 @@ export const userStub = {
}), }),
user2: Object.freeze<UserEntity>({ user2: Object.freeze<UserEntity>({
...authStub.user2.user, ...authStub.user2.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password', password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: null, storageLabel: null,
@ -63,6 +69,9 @@ export const userStub = {
}), }),
storageLabel: Object.freeze<UserEntity>({ storageLabel: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password', password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: 'label-1', storageLabel: 'label-1',
@ -79,6 +88,9 @@ export const userStub = {
}), }),
profilePath: Object.freeze<UserEntity>({ profilePath: Object.freeze<UserEntity>({
...authStub.user1.user, ...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password', password: 'immich_password',
name: 'immich_name', name: 'immich_name',
storageLabel: 'label-1', storageLabel: 'label-1',

View File

@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest';
export const newUserRepositoryMock = (): Mocked<RepositoryInterface<UserRepository>> => { export const newUserRepositoryMock = (): Mocked<RepositoryInterface<UserRepository>> => {
return { return {
get: vitest.fn(), get: vitest.fn(),
getMetadata: vitest.fn().mockResolvedValue([]),
getAdmin: vitest.fn(), getAdmin: vitest.fn(),
getByEmail: vitest.fn(), getByEmail: vitest.fn(),
getByStorageLabel: vitest.fn(), getByStorageLabel: vitest.fn(),