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:
parent
7c821dd205
commit
2d7c333c8c
25 changed files with 265 additions and 239 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
26
server/src/db.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
66
server/test/fixtures/auth.stub.ts
vendored
66
server/test/fixtures/auth.stub.ts
vendored
|
@ -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,
|
||||
|
|
16
server/test/fixtures/user.stub.ts
vendored
16
server/test/fixtures/user.stub.ts
vendored
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Add table
Reference in a new issue