0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-18 01:24:26 -05:00

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')
@Authenticated()
getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto {
getMyUser(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
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<UserPreferencesResponseDto> {
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<LicenseResponseDto> {
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 = {
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;

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

@ -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<T> = ArrayTypeImpl<T> extends (infer U)[]
? U[]
: ArrayTypeImpl<T>;
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S[], I[], U[]>
: T[];
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? 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>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : 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;
@ -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<Date, Date | string, Date | string>;
@ -81,7 +77,7 @@ export interface ApiKeys {
id: Generated<string>;
key: string;
name: string;
permissions: string[];
permissions: Permission[];
updatedAt: Generated<Timestamp>;
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;
}

View file

@ -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 {

View file

@ -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,
};
};

View file

@ -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<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
@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' })
userId!: string;
@ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user!: UserEntity;
user?: UserEntity;
@PrimaryColumn({ type: 'varchar' })
key!: T;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<DB>) {}
@ -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();

View file

@ -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();
}

View file

@ -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<SharedLinkEntity | undefined>;
}
@GenerateSql({ params: [DummyValue.UUID] })
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
getAll({ userId, albumId }: SharedLinkSearchOptions): Promise<SharedLinkEntity[]> {
return this.db
.selectFrom('shared_links')
@ -160,39 +161,20 @@ export class SharedLinkRepository {
}
@GenerateSql({ params: [DummyValue.BUFFER] })
async getByKey(key: Buffer): Promise<SharedLinkEntity | undefined> {
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<SharedLinkEntity | undefined>;
.executeTakeFirst();
}
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 { 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<UserEntity | undefined>;
}
getMetadata(userId: string) {
return this.db
.selectFrom('user_metadata')
.select(['key', 'value'])
.where('user_metadata.userId', '=', userId)
.execute() as Promise<UserMetadataItem[]>;
}
@GenerateSql()
getAdmin(): Promise<UserEntity | undefined> {
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<number>('exif.fileSizeInByte'), eb.lit(0)).as('usage'))
.where('assets.libraryId', 'is', null)
.where('assets.ownerId', '=', eb.ref('users.id')),
updatedAt: new Date(),

View file

@ -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<AuthDto> {
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<AuthDto> {
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');

View file

@ -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) {

View file

@ -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;

View file

@ -106,21 +106,24 @@ export class UserAdminService extends BaseService {
}
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
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) {

View file

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

View file

@ -22,16 +22,24 @@ export class UserService extends BaseService {
async search(auth: AuthDto): Promise<UserResponseDto[]> {
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<UserAdminResponseDto> {
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> {
@ -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<UserPreferencesResponseDto> {
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<UserResponseDto> {
@ -120,8 +131,10 @@ export class UserService extends BaseService {
});
}
getLicense({ user }: AuthDto): LicenseResponseDto {
const license = user.metadata.find(
async getLicense(auth: AuthDto): Promise<LicenseResponseDto> {
const metadata = await this.userRepository.getMetadata(auth.user.id);
const license = metadata.find(
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
);
if (!license) {

View file

@ -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> = 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>;
type IActivityRepository = RepositoryInterface<ActivityRepository>;

View file

@ -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> | 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> };
export const requireUploadAccess = (auth: AuthDto | null): AuthDto => {

View file

@ -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));
}

View file

@ -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<AuthDto>({
user: {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
}),
admin: Object.freeze<AuthDto>({ user: authUser.admin }),
user1: Object.freeze<AuthDto>({
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<AuthDto>({
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<AuthDto>({
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<AuthDto>({
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<AuthDto>({
user: {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
user: authUser.admin,
sharedLink: {
id: '123',
allowUpload: false,

View file

@ -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<UserEntity>({
...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<UserEntity>({
...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<UserEntity>({
...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<UserEntity>({
...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<UserEntity>({
...authStub.user1.user,
status: UserStatus.ACTIVE,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',

View file

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