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
25 changed files with 265 additions and 239 deletions
+3 -3
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);
} }
+50
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;
+11 -15
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;
} }
+4 -6
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 {
+1 -1
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,
}; };
}; };
+7 -2
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;
+13 -14
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
"users".*,
( (
select select
array_agg("user_metadata") as "metadata" to_json(obj)
from from
"user_metadata" (
where select
"users"."id" = "user_metadata"."userId" "users"."id",
) as "metadata" "users"."name",
"users"."email",
"users"."isAdmin",
"users"."quotaUsageInBytes",
"users"."quotaSizeInBytes"
from from
"users" "users"
where where
"users"."id" = "api_keys"."userId" "users"."id" = "api_keys"."userId"
and "users"."deletedAt" is null and "users"."deletedAt" is null
) as "user" on true ) as obj
) as "user"
from
"api_keys"
where where
"api_keys"."key" = $1 "api_keys"."key" = $1
+15 -27
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
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
( (
select select
array_agg("user_metadata") as "metadata" to_json(obj)
from from
"user_metadata" (
where select
"users"."id" = "user_metadata"."userId" "users"."id",
) as "metadata" "users"."name",
"users"."email",
"users"."isAdmin",
"users"."quotaUsageInBytes",
"users"."quotaSizeInBytes"
from from
"users" "users"
where where
"users"."id" = "sessions"."userId" "users"."id" = "sessions"."userId"
and "users"."deletedAt" is null and "users"."deletedAt" is null
) as "user" on true ) as obj
) as "user"
from
"sessions"
where where
"sessions"."token" = $1 "sessions"."token" = $1
+11 -13
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
+10 -24
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();
+12 -3
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();
} }
@@ -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> {
+10 -2
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(),
+12 -12
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');
+2 -1
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) {
+2 -2
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;
+10 -7
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) {
+2 -2
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,
}); });
+25 -12
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) {
-9
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>;
+2 -2
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 => {
+5 -11
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));
} }
+25 -33
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';
export const authStub = { const authUser = {
admin: Object.freeze<AuthDto>({ admin: {
user: {
id: 'admin_id', id: 'admin_id',
name: 'admin',
email: 'admin@test.com', email: 'admin@test.com',
isAdmin: true, isAdmin: true,
metadata: [] as UserMetadataEntity[], quotaSizeInBytes: null,
} as UserEntity, quotaUsageInBytes: 0,
}), },
user1: Object.freeze<AuthDto>({ user1: {
user: {
id: 'user-id', id: 'user-id',
name: 'User 1',
email: 'immich@test.com', email: 'immich@test.com',
isAdmin: false, isAdmin: false,
metadata: [] as UserMetadataEntity[], quotaSizeInBytes: null,
} as UserEntity, quotaUsageInBytes: 0,
},
};
export const authStub = {
admin: Object.freeze<AuthDto>({ user: authUser.admin }),
user1: Object.freeze<AuthDto>({
user: authUser.user1,
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,
+14 -2
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',
@@ -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(),