merge main

This commit is contained in:
martabal
2024-05-28 00:04:45 +02:00
140 changed files with 2907 additions and 2197 deletions
@@ -1,9 +1,9 @@
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
import { UserResponseDto } from 'src/dtos/user.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => {
return function ask(admin: UserResponseDto) {
return function ask(admin: UserAdminResponseDto) {
const { id, oauthId, email, name } = admin;
console.log(`Found Admin:
- ID=${id}
+2 -2
View File
@@ -256,8 +256,8 @@ export const defaults = Object.freeze<SystemConfig>({
modelName: 'ViT-B-32__openai',
},
duplicateDetection: {
enabled: false,
maxDistance: 0.03,
enabled: true,
maxDistance: 0.0155,
},
facialRecognition: {
enabled: true,
+4 -4
View File
@@ -12,7 +12,7 @@ import {
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@@ -40,7 +40,7 @@ export class AuthController {
}
@Post('admin-sign-up')
signUpAdmin(@Body() dto: SignUpDto): Promise<UserResponseDto> {
signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
return this.service.adminSignUp(dto);
}
@@ -54,8 +54,8 @@ export class AuthController {
@Post('change-password')
@HttpCode(HttpStatus.OK)
@Authenticated()
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.service.changePassword(auth, dto).then(mapUser);
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto);
}
@Post('logout')
+2
View File
@@ -27,6 +27,7 @@ import { SystemMetadataController } from 'src/controllers/system-metadata.contro
import { TagController } from 'src/controllers/tag.controller';
import { TimelineController } from 'src/controllers/timeline.controller';
import { TrashController } from 'src/controllers/trash.controller';
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserController } from 'src/controllers/user.controller';
export const controllers = [
@@ -59,5 +60,6 @@ export const controllers = [
TagController,
TimelineController,
TrashController,
UserAdminController,
UserController,
];
+3 -3
View File
@@ -10,7 +10,7 @@ import {
OAuthCallbackDto,
OAuthConfigDto,
} from 'src/dtos/auth.dto';
import { UserResponseDto } from 'src/dtos/user.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@@ -53,13 +53,13 @@ export class OAuthController {
@Post('link')
@Authenticated()
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> {
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
return this.service.link(auth, dto);
}
@Post('unlink')
@Authenticated()
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserResponseDto> {
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
}
@@ -0,0 +1,63 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
UserAdminSearchDto,
UserAdminUpdateDto,
} from 'src/dtos/user.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserAdminService } from 'src/services/user-admin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('User')
@Controller('admin/users')
export class UserAdminController {
constructor(private service: UserAdminService) {}
@Get()
@Authenticated({ admin: true })
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ admin: true })
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
return this.service.create(createUserDto);
}
@Get(':id')
@Authenticated({ admin: true })
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ admin: true })
updateUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserAdminUpdateDto,
): Promise<UserAdminResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ admin: true })
deleteUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserAdminDeleteDto,
): Promise<UserAdminResponseDto> {
return this.service.delete(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ admin: true })
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.restore(auth, id);
}
}
+18 -42
View File
@@ -10,7 +10,6 @@ import {
Param,
Post,
Put,
Query,
Res,
UploadedFile,
UseInterceptors,
@@ -19,7 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
@@ -37,58 +36,28 @@ export class UserController {
@Get()
@Authenticated()
getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
return this.service.getAll(auth, isAll);
}
@Post()
@Authenticated({ admin: true })
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.service.create(createUserDto);
searchUsers(): Promise<UserResponseDto[]> {
return this.service.search();
}
@Get('me')
@Authenticated()
getMyUserInfo(@Auth() auth: AuthDto): Promise<UserResponseDto> {
getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto {
return this.service.getMe(auth);
}
@Put('me')
@Authenticated()
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get(':id')
@Authenticated()
getUserById(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id);
}
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Delete(':id')
@Authenticated({ admin: true })
deleteUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: DeleteUserDto,
): Promise<UserResponseDto> {
return this.service.delete(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ admin: true })
restoreUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.restore(auth, id);
}
// TODO: replace with @Put(':id')
@Put()
@Authenticated()
updateUser(@Auth() auth: AuthDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
return this.service.update(auth, updateUserDto);
}
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@@ -101,6 +70,13 @@ export class UserController {
return this.service.createProfileImage(auth, fileInfo);
}
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Get(':id/profile-image')
@FileResponse()
@Authenticated()
+1 -42
View File
@@ -1,7 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants';
import { UserResponseDto } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
@@ -26,46 +25,6 @@ export class UserCore {
instance = null;
}
// TODO: move auth related checks to the service layer
async updateUser(user: UserEntity | UserResponseDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
if (!user.isAdmin && user.id !== id) {
throw new ForbiddenException('You are not allowed to update this user');
}
if (!user.isAdmin) {
// Users can never update the isAdmin property.
delete dto.isAdmin;
delete dto.storageLabel;
} else if (dto.isAdmin && user.id !== id) {
// Admin cannot create another admin.
throw new BadRequestException('The server already has an admin');
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Email already in use by another account');
}
}
if (dto.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Storage label already in use by another account');
}
}
if (dto.password) {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
return this.userRepository.update(id, { ...dto, updatedAt: new Date() });
}
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
+3 -3
View File
@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { UserDto, mapSimpleUser } from 'src/dtos/user.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { ActivityEntity } from 'src/entities/activity.entity';
import { Optional, ValidateUUID } from 'src/validation';
@@ -20,7 +20,7 @@ export class ActivityResponseDto {
id!: string;
createdAt!: Date;
type!: ReactionType;
user!: UserDto;
user!: UserResponseDto;
assetId!: string | null;
comment?: string | null;
}
@@ -73,6 +73,6 @@ export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
createdAt: activity.createdAt,
comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapSimpleUser(activity.user),
user: mapUser(activity.user),
};
}
+7 -22
View File
@@ -1,12 +1,12 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto';
import { UserAdminCreateDto, UserUpdateMeDto } from 'src/dtos/user.dto';
describe('update user DTO', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(UpdateUserDto, {
const dto = plainToInstance(UserUpdateMeDto, {
email: someEmail,
id: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
});
@@ -18,22 +18,22 @@ describe('update user DTO', () => {
describe('create user DTO', () => {
it('validates the email', async () => {
const params: Partial<CreateUserDto> = {
const params: Partial<UserAdminCreateDto> = {
email: undefined,
password: 'password',
name: 'name',
};
let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params);
let errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'invalid email';
dto = plainToInstance(CreateUserDto, params);
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'valid@email.com';
dto = plainToInstance(CreateUserDto, params);
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(0);
});
@@ -41,7 +41,7 @@ describe('create user DTO', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(CreateUserDto, {
const dto = plainToInstance(UserAdminCreateDto, {
email: someEmail,
password: 'some password',
name: 'some name',
@@ -51,18 +51,3 @@ describe('create user DTO', () => {
expect(dto.email).toEqual(someEmail);
});
});
describe('create user oauth DTO', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(CreateUserOAuthDto, {
email: someEmail,
oauthId: 'some oauth id',
name: 'some name',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual(someEmail);
});
});
+62 -50
View File
@@ -1,12 +1,63 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class CreateUserDto {
export class UserUpdateMeDto {
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
email?: string;
// TODO: migrate to the other change password endpoint
@Optional()
@IsNotEmpty()
@IsString()
password?: string;
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
}
export class UserResponseDto {
id!: string;
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
}
export const mapUser = (entity: UserEntity): UserResponseDto => {
return {
id: entity.id,
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
};
};
export class UserAdminSearchDto {
@ValidateBoolean({ optional: true })
withDeleted?: boolean;
}
export class UserAdminCreateDto {
@IsEmail({ require_tld: false })
@Transform(toEmail)
email!: string;
@@ -41,23 +92,7 @@ export class CreateUserDto {
notify?: boolean;
}
export class CreateUserOAuthDto {
@IsEmail({ require_tld: false })
@Transform(({ value }) => value?.toLowerCase())
email!: string;
@IsNotEmpty()
oauthId!: string;
name?: string;
}
export class DeleteUserDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
export class UpdateUserDto {
export class UserAdminUpdateDto {
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
@@ -73,18 +108,10 @@ export class UpdateUserDto {
@IsNotEmpty()
name?: string;
@Optional()
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)
storageLabel?: string;
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
id!: string;
@ValidateBoolean({ optional: true })
isAdmin?: boolean;
storageLabel?: string | null;
@ValidateBoolean({ optional: true })
shouldChangePassword?: boolean;
@@ -104,17 +131,12 @@ export class UpdateUserDto {
quotaSizeInBytes?: number | null;
}
export class UserDto {
id!: string;
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
export class UserAdminDeleteDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
export class UserResponseDto extends UserDto {
export class UserAdminResponseDto extends UserResponseDto {
storageLabel!: string | null;
shouldChangePassword!: boolean;
isAdmin!: boolean;
@@ -131,19 +153,9 @@ export class UserResponseDto extends UserDto {
status!: string;
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
return {
id: entity.id,
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
};
};
export function mapUser(entity: UserEntity): UserResponseDto {
return {
...mapSimpleUser(entity),
...mapUser(entity),
storageLabel: entity.storageLabel,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
+3 -3
View File
@@ -53,7 +53,7 @@ export interface VideoInfo {
audioStreams: AudioStreamInfo[];
}
export interface TranscodeOptions {
export interface TranscodeCommand {
inputOptions: string[];
outputOptions: string[];
twoPass: boolean;
@@ -67,7 +67,7 @@ export interface BitrateDistribution {
}
export interface VideoCodecSWConfig {
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeOptions;
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
}
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
@@ -83,5 +83,5 @@ export interface IMediaRepository {
// video
probe(input: string): Promise<VideoInfo>;
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void>;
transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise<void>;
}
+2 -1
View File
@@ -155,8 +155,9 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface AssetDuplicateSearch {
assetId: string;
embedding: Embedding;
userIds: string[];
maxDistance?: number;
type: AssetType;
userIds: string[];
}
export interface FaceSearchResult {
+5 -1
View File
@@ -22,13 +22,17 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes"
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value"
FROM
"api_keys" "APIKeyEntity"
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"
AND (
"APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL
)
LEFT JOIN "user_metadata" "7f5f7a38bf327bfbbf826778460704c9a50fe6f4" ON "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" = "APIKeyEntity__APIKeyEntity_user"."id"
WHERE
(("APIKeyEntity"."key" = $1))
) "distinctAlias"
+2 -1
View File
@@ -204,6 +204,7 @@ WITH
"asset"."ownerId" IN ($2)
AND "asset"."id" != $3
AND "asset"."isVisible" = $4
AND "asset"."type" = $5
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
@@ -216,7 +217,7 @@ SELECT
FROM
"cte" "res"
WHERE
res.distance <= $5
res.distance <= $6
-- SearchRepository.searchFaces
START TRANSACTION
+5 -1
View File
@@ -38,13 +38,17 @@ FROM
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes"
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value"
FROM
"sessions" "SessionEntity"
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
AND (
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
)
LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id"
WHERE
(("SessionEntity"."token" = $1))
) "distinctAlias"
@@ -34,7 +34,9 @@ export class ApiKeyRepository implements IKeyRepository {
},
where: { key: hashedToken },
relations: {
user: true,
user: {
metadata: true,
},
},
});
}
+3 -3
View File
@@ -11,7 +11,7 @@ import {
IMediaRepository,
ImageDimensions,
ThumbnailOptions,
TranscodeOptions,
TranscodeCommand,
VideoInfo,
} from 'src/interfaces/media.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@@ -97,7 +97,7 @@ export class MediaRepository implements IMediaRepository {
};
}
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
@@ -150,7 +150,7 @@ export class MediaRepository implements IMediaRepository {
return { width, height };
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
return ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
+9 -4
View File
@@ -160,6 +160,7 @@ export class SearchRepository implements ISearchRepository {
assetId,
embedding,
maxDistance,
type,
userIds,
}: AssetDuplicateSearch): Promise<AssetDuplicateResult[]> {
const cte = this.assetRepository.createQueryBuilder('asset');
@@ -171,18 +172,22 @@ export class SearchRepository implements ISearchRepository {
.where('asset.ownerId IN (:...userIds )')
.andWhere('asset.id != :assetId')
.andWhere('asset.isVisible = :isVisible')
.andWhere('asset.type = :type')
.orderBy('search.embedding <=> :embedding')
.limit(64)
.setParameters({ assetId, embedding: asVector(embedding), isVisible: true, userIds });
.setParameters({ assetId, embedding: asVector(embedding), isVisible: true, type, userIds });
const builder = this.assetRepository.manager
.createQueryBuilder()
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.select('res.*')
.where('res.distance <= :maxDistance', { maxDistance });
.select('res.*');
return builder.getRawMany() as any as Promise<AssetDuplicateResult[]>;
if (maxDistance) {
builder.where('res.distance <= :maxDistance', { maxDistance });
}
return builder.getRawMany() as Promise<AssetDuplicateResult[]>;
}
@GenerateSql({
@@ -18,7 +18,14 @@ export class SessionRepository implements ISessionRepository {
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });
return this.repository.findOne({
where: { token },
relations: {
user: {
metadata: true,
},
},
});
}
getByUserId(userId: string): Promise<SessionEntity[]> {
+1
View File
@@ -138,6 +138,7 @@ describe('AuthService', () => {
email: 'test@immich.com',
password: 'hash-password',
} as UserEntity);
userMock.update.mockResolvedValue(userStub.user1);
await sut.changePassword(auth, dto);
+17 -10
View File
@@ -11,7 +11,7 @@ import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { SystemConfig } from 'src/config';
import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants';
import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import {
@@ -27,7 +27,7 @@ import {
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@@ -109,7 +109,7 @@ export class AuthService {
};
}
async changePassword(auth: AuthDto, dto: ChangePasswordDto) {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(auth.user.email, true);
if (!user) {
@@ -121,10 +121,14 @@ export class AuthService {
throw new BadRequestException('Wrong password');
}
return this.userCore.updateUser(auth.user, auth.user.id, { password: newPassword });
const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS);
const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword });
return mapUserAdmin(updatedUser);
}
async adminSignUp(dto: SignUpDto): Promise<UserResponseDto> {
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const adminUser = await this.userRepository.getAdmin();
if (adminUser) {
throw new BadRequestException('The server already has an admin');
@@ -138,7 +142,7 @@ export class AuthService {
storageLabel: 'admin',
});
return mapUser(admin);
return mapUserAdmin(admin);
}
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
@@ -237,7 +241,7 @@ export class AuthService {
return this.createLoginResponse(user, loginDetails);
}
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
const config = await this.configCore.getConfig();
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
@@ -245,11 +249,14 @@ export class AuthService {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return mapUser(await this.userRepository.update(auth.user.id, { oauthId }));
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
}
async unlink(auth: AuthDto): Promise<UserResponseDto> {
return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' }));
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
+8 -9
View File
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -10,7 +10,6 @@ import { IUserRepository } from 'src/interfaces/user.interface';
@Injectable()
export class CliService {
private configCore: SystemConfigCore;
private userCore: UserCore;
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@@ -18,26 +17,26 @@ export class CliService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(CliService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async listUsers(): Promise<UserResponseDto[]> {
async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUser(user));
return users.map((user) => mapUserAdmin(user));
}
async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) {
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
const admin = await this.userRepository.getAdmin();
if (!admin) {
throw new Error('Admin account does not exist');
}
const providedPassword = await ask(mapUser(admin));
const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.newPassword(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userCore.updateUser(admin, admin.id, { password });
await this.userRepository.update(admin.id, { password: hashedPassword });
return { admin, password, provided: !!providedPassword };
}
@@ -214,7 +214,8 @@ describe(SearchService.name, () => {
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.03,
maxDistance: 0.0155,
type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId],
});
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
@@ -239,7 +240,8 @@ describe(SearchService.name, () => {
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.03,
maxDistance: 0.0155,
type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId],
});
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
+1
View File
@@ -94,6 +94,7 @@ export class DuplicateService {
assetId: asset.id,
embedding: asset.smartSearch.embedding,
maxDistance: machineLearning.duplicateDetection.maxDistance,
type: asset.type,
userIds: [asset.ownerId],
});
+2
View File
@@ -33,6 +33,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
@@ -73,5 +74,6 @@ export const services = [
TimelineService,
TrashService,
UserService,
UserAdminService,
VersionService,
];
+12 -6
View File
@@ -294,11 +294,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`,
],
twoPass: false,
},
@@ -319,11 +321,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},
@@ -346,11 +350,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},
+37 -111
View File
@@ -27,25 +27,12 @@ import {
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from 'src/interfaces/media.interface';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import {
AV1Config,
H264Config,
HEVCConfig,
NvencHwDecodeConfig,
NvencSwDecodeConfig,
QsvHwDecodeConfig,
QsvSwDecodeConfig,
RkmppHwDecodeConfig,
RkmppSwDecodeConfig,
ThumbnailConfig,
VAAPIConfig,
VP9Config,
} from 'src/utils/media';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination';
@@ -53,8 +40,8 @@ import { usePagination } from 'src/utils/pagination';
export class MediaService {
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private openCL: boolean | null = null;
private devices: string[] | null = null;
private maliOpenCL?: boolean;
private devices?: string[];
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@@ -232,8 +219,8 @@ export class MediaService {
return;
}
const mainAudioStream = this.getMainStream(audioStreams);
const config = { ...ffmpeg, targetResolution: size.toString() };
const options = new ThumbnailConfig(config).getOptions(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() });
const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
break;
}
@@ -331,8 +318,8 @@ export class MediaService {
return JobStatus.FAILED;
}
const { ffmpeg: config } = await this.configCore.getConfig();
const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream);
const { ffmpeg } = await this.configCore.getConfig();
const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
if (target === TranscodeTarget.NONE) {
if (asset.encodedVideoPath) {
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
@@ -343,30 +330,28 @@ export class MediaService {
return JobStatus.SKIPPED;
}
let transcodeOptions;
let command;
try {
transcodeOptions = await this.getCodecConfig(config).then((c) =>
c.getOptions(target, mainVideoStream, mainAudioStream),
);
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
command = config.getCommand(target, mainVideoStream, mainAudioStream);
} catch (error) {
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
return JobStatus.FAILED;
}
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`);
try {
await this.mediaRepository.transcode(input, output, transcodeOptions);
await this.mediaRepository.transcode(input, output, command);
} catch (error) {
this.logger.error(error);
if (config.accel !== TranscodeHWAccel.DISABLED) {
if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) {
this.logger.error(
`Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`,
`Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`,
);
}
transcodeOptions = await this.getCodecConfig({ ...config, accel: TranscodeHWAccel.DISABLED }).then((c) =>
c.getOptions(target, mainVideoStream, mainAudioStream),
);
await this.mediaRepository.transcode(input, output, transcodeOptions);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
}
this.logger.log(`Successfully encoded ${asset.id}`);
@@ -382,10 +367,10 @@ export class MediaService {
private getTranscodeTarget(
config: SystemConfigFFmpegDto,
videoStream: VideoStreamInfo | null,
audioStream: AudioStreamInfo | null,
videoStream?: VideoStreamInfo,
audioStream?: AudioStreamInfo,
): TranscodeTarget {
if (videoStream == null && audioStream == null) {
if (!videoStream && !audioStream) {
return TranscodeTarget.NONE;
}
@@ -407,8 +392,8 @@ export class MediaService {
return TranscodeTarget.NONE;
}
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: AudioStreamInfo | null): boolean {
if (stream == null) {
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
if (!stream) {
return false;
}
@@ -430,8 +415,8 @@ export class MediaService {
}
}
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo | null): boolean {
if (stream == null) {
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
if (!stream) {
return false;
}
@@ -465,70 +450,6 @@ export class MediaService {
}
}
async getCodecConfig(config: SystemConfigFFmpegDto) {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config);
}
private getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) {
case VideoCodec.H264: {
return new H264Config(config);
}
case VideoCodec.HEVC: {
return new HEVCConfig(config);
}
case VideoCodec.VP9: {
return new VP9Config(config);
}
case VideoCodec.AV1: {
return new AV1Config(config);
}
default: {
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
}
}
}
private async getHWCodecConfig(config: SystemConfigFFmpegDto) {
let handler: VideoCodecHWConfig;
switch (config.accel) {
case TranscodeHWAccel.NVENC: {
handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
break;
}
case TranscodeHWAccel.QSV: {
handler = config.accelDecode
? new QsvHwDecodeConfig(config, await this.getDevices())
: new QsvSwDecodeConfig(config, await this.getDevices());
break;
}
case TranscodeHWAccel.VAAPI: {
handler = new VAAPIConfig(config, await this.getDevices());
break;
}
case TranscodeHWAccel.RKMPP: {
handler =
config.accelDecode && (await this.hasOpenCL())
? new RkmppHwDecodeConfig(config, await this.getDevices())
: new RkmppSwDecodeConfig(config, await this.getDevices());
break;
}
default: {
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
}
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new UnsupportedMediaTypeException(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
);
}
return handler;
}
isSRGB(asset: AssetEntity): boolean {
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
if (colorspace || profileDescription) {
@@ -567,24 +488,29 @@ export class MediaService {
private async getDevices() {
if (!this.devices) {
this.devices = await this.storageRepository.readdir('/dev/dri');
try {
this.devices = await this.storageRepository.readdir('/dev/dri');
} catch {
this.logger.debug('No devices found in /dev/dri.');
this.devices = [];
}
}
return this.devices;
}
private async hasOpenCL() {
if (this.openCL === null) {
private async hasMaliOpenCL() {
if (this.maliOpenCL === undefined) {
try {
const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.openCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.warn('OpenCL not available for transcoding, using CPU instead.');
this.openCL = false;
this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.');
this.maliOpenCL = false;
}
}
return this.openCL;
return this.maliOpenCL;
}
}
+3 -44
View File
@@ -1,6 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { PartnerResponseDto } from 'src/dtos/partner.dto';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service';
@@ -9,45 +7,6 @@ import { partnerStub } from 'test/fixtures/partner.stub';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest';
const responseDto = {
admin: <PartnerResponseDto>{
email: 'admin@test.com',
name: 'admin_name',
id: 'admin_id',
isAdmin: true,
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
storageLabel: 'admin',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
memoriesEnabled: true,
avatarColor: UserAvatarColor.GRAY,
quotaSizeInBytes: null,
inTimeline: true,
quotaUsageInBytes: 0,
},
user1: <PartnerResponseDto>{
email: 'immich@test.com',
name: 'immich_name',
id: 'user-id',
isAdmin: false,
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
storageLabel: null,
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};
describe(PartnerService.name, () => {
let sut: PartnerService;
let partnerMock: Mocked<IPartnerRepository>;
@@ -65,13 +24,13 @@ describe(PartnerService.name, () => {
describe('getAll', () => {
it("should return a list of partners with whom I've shared my library", async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
it('should return a list of partners who have shared their libraries with me', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
@@ -81,7 +40,7 @@ describe(PartnerService.name, () => {
partnerMock.get.mockResolvedValue(null);
partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toEqual(responseDto.user1);
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined();
expect(partnerMock.create).toHaveBeenCalledWith({
sharedById: authStub.admin.user.id,
+4 -4
View File
@@ -25,7 +25,7 @@ export class PartnerService {
}
const partner = await this.repository.create(partnerId);
return this.mapToPartnerEntity(partner, PartnerDirection.SharedBy);
return this.mapPartner(partner, PartnerDirection.SharedBy);
}
async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
@@ -44,7 +44,7 @@ export class PartnerService {
return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
.filter((partner) => partner[key] === auth.user.id)
.map((partner) => this.mapToPartnerEntity(partner, direction));
.map((partner) => this.mapPartner(partner, direction));
}
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
@@ -52,10 +52,10 @@ export class PartnerService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.mapToPartnerEntity(entity, PartnerDirection.SharedWith);
return this.mapPartner(entity, PartnerDirection.SharedWith);
}
private mapToPartnerEntity(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
private mapPartner(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner"
const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
@@ -149,7 +149,7 @@ describe(ServerInfoService.name, () => {
it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({
smartSearch: true,
duplicateDetection: false,
duplicateDetection: true,
facialRecognition: true,
map: true,
reverseGeocoding: true,
@@ -81,8 +81,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
modelName: 'ViT-B-32__openai',
},
duplicateDetection: {
enabled: false,
maxDistance: 0.03,
enabled: true,
maxDistance: 0.0155,
},
facialRecognition: {
enabled: true,
@@ -0,0 +1,197 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto';
import { UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe } from 'vitest';
describe(UserAdminService.name, () => {
let sut: UserAdminService;
let userMock: Mocked<IUserRepository>;
let cryptoRepositoryMock: Mocked<ICryptoRepository>;
let albumMock: Mocked<IAlbumRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserAdminService(albumMock, cryptoRepositoryMock, jobMock, userMock, loggerMock);
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),
);
});
describe('create', () => {
it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null);
await expect(
sut.create({
email: 'john_smith@email.com',
name: 'John Smith',
password: 'password',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create user', async () => {
userMock.getAdmin.mockResolvedValue(userStub.admin);
userMock.create.mockResolvedValue(userStub.user1);
await expect(
sut.create({
email: userStub.user1.email,
name: userStub.user1.name,
password: 'password',
storageLabel: 'label',
}),
).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.getAdmin).toBeCalled();
expect(userMock.create).toBeCalledWith({
email: userStub.user1.email,
name: userStub.user1.name,
storageLabel: 'label',
password: expect.anything(),
});
});
});
describe('update', () => {
it('should update the user', async () => {
const update = {
shouldChangePassword: true,
email: 'immich@test.com',
storageLabel: 'storage_label',
};
userMock.getByEmail.mockResolvedValue(null);
userMock.getByStorageLabel.mockResolvedValue(null);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.user1, userStub.user1.id, update);
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
});
it('should not set an empty string for storage label', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
storageLabel: null,
updatedAt: expect.any(Date),
});
});
it('should not change an email to one already in use', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByEmail.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null);
await expect(
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('delete', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUserAdmin(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
});
+154
View File
@@ -0,0 +1,154 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import {
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
UserAdminSearchDto,
UserAdminUpdateDto,
mapUserAdmin,
} from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
@Injectable()
export class UserAdminService {
private userCore: UserCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(UserAdminService.name);
}
async search(auth: AuthDto, dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: dto.withDeleted });
return users.map((user) => mapUserAdmin(user));
}
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
const { memoriesEnabled, notify, ...rest } = dto;
let user = await this.userCore.createUser(rest);
// TODO remove and replace with entire dto.preferences config
if (memoriesEnabled === false) {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: { memories: { enabled: false } },
});
user = await this.findOrFail(user.id, {});
}
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUserAdmin(user);
}
async get(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
const user = await this.findOrFail(id, { withDeleted: true });
return mapUserAdmin(user);
}
async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise<UserAdminResponseDto> {
const user = await this.findOrFail(id, {});
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
await this.userRepository.syncUsage(id);
}
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
if (dto.memoriesEnabled !== undefined) {
newPreferences.memories.enabled = dto.memoriesEnabled;
delete dto.memoriesEnabled;
}
if (dto.avatarColor) {
newPreferences.avatar.color = dto.avatarColor;
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences),
});
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Email already in use by another account');
}
}
if (dto.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Storage label already in use by another account');
}
}
if (dto.password) {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
const updatedUser = await this.userRepository.update(id, { ...dto, updatedAt: new Date() });
return mapUserAdmin(updatedUser);
}
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
return mapUserAdmin(user);
}
async restore(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
const user = await this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE });
return mapUserAdmin(user);
}
private async findOrFail(id: string, options: UserFindOptions) {
const user = await this.userRepository.get(id, options);
if (!user) {
throw new BadRequestException('User not found');
}
return user;
}
}
+10 -254
View File
@@ -1,11 +1,5 @@
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto, mapUser } from 'src/dtos/user.dto';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@@ -63,13 +57,13 @@ describe(UserService.name, () => {
describe('getAll', () => {
it('should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([
await expect(sut.search()).resolves.toEqual([
expect.objectContaining({
id: authStub.admin.user.id,
email: authStub.admin.user.email,
}),
]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true });
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
});
});
@@ -82,255 +76,17 @@ describe(UserService.name, () => {
it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
});
});
describe('getMe', () => {
it("should get the auth user's info", async () => {
userMock.get.mockResolvedValue(userStub.admin);
await sut.getMe(authStub.admin);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
});
it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.getMe(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
});
});
describe('update', () => {
it('should update user', async () => {
const update: UpdateUserDto = {
id: userStub.user1.id,
shouldChangePassword: true,
email: 'immich@test.com',
storageLabel: 'storage_label',
};
userMock.getByEmail.mockResolvedValue(null);
userMock.getByStorageLabel.mockResolvedValue(null);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: { ...authStub.user1.user, isAdmin: true } }, update);
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
});
it('should not set an empty string for storage label', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.admin, { id: userStub.user1.id, storageLabel: '' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
storageLabel: null,
updatedAt: expect.any(Date),
});
});
it('should omit a storage label set by non-admin users', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
updatedAt: expect.any(Date),
});
});
it('user can only update its information', async () => {
userMock.get.mockResolvedValueOnce({
...userStub.user1,
id: 'not_immich_auth_user_id',
});
const result = sut.update(
{ user: userStub.user1 },
{
id: 'not_immich_auth_user_id',
password: 'I take over your account now',
},
);
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
});
it('should let a user change their email', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: userStub.user1 }, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
email: 'updated@test.com',
updatedAt: expect.any(Date),
});
});
it('should not let a user change their email to one already in use', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByEmail.mockResolvedValue(userStub.admin);
await expect(sut.update({ user: userStub.user1 }, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('admin can update any user information', async () => {
const update: UpdateUserDto = {
id: userStub.user1.id,
shouldChangePassword: true,
};
userMock.update.mockResolvedValueOnce(userStub.user1);
await sut.update(authStub.admin, update);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
shouldChangePassword: true,
updatedAt: expect.any(Date),
});
});
it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null);
const result = sut.update(authStub.admin, {
id: userStub.user1.id,
shouldChangePassword: true,
});
await expect(result).rejects.toBeInstanceOf(BadRequestException);
});
it('should let the admin update himself', async () => {
const dto = { id: userStub.admin.id, shouldChangePassword: true, isAdmin: true };
userMock.update.mockResolvedValueOnce(userStub.admin);
await sut.update(authStub.admin, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) });
});
it('should not let the another user become an admin', async () => {
const dto = { id: userStub.user1.id, shouldChangePassword: true, isAdmin: true };
userMock.get.mockResolvedValueOnce(userStub.user1);
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
describe('delete', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUser(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
describe('create', () => {
it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null);
await expect(
sut.create({
email: 'john_smith@email.com',
name: 'John Smith',
password: 'password',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create user', async () => {
userMock.getAdmin.mockResolvedValue(userStub.admin);
userMock.create.mockResolvedValue(userStub.user1);
await expect(
sut.create({
email: userStub.user1.email,
name: userStub.user1.name,
password: 'password',
storageLabel: 'label',
}),
).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.getAdmin).toBeCalled();
expect(userMock.create).toBeCalledWith({
email: userStub.user1.email,
name: userStub.user1.name,
storageLabel: 'label',
password: expect.anything(),
it("should get the auth user's info", () => {
const user = authStub.admin.user;
expect(sut.getMe(authStub.admin)).toMatchObject({
id: user.id,
email: user.email,
});
});
});
+33 -78
View File
@@ -1,13 +1,13 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@@ -21,73 +21,30 @@ import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
@Injectable()
export class UserService {
private configCore: SystemConfigCore;
private userCore: UserCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(UserService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async listUsers(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
async search(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: false });
return users.map((user) => mapUser(user));
}
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: !isAll });
return users.map((user) => mapUser(user));
getMe(auth: AuthDto): UserAdminResponseDto {
return mapUserAdmin(auth.user);
}
async get(userId: string): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId, { withDeleted: false });
if (!user) {
throw new NotFoundException('User not found');
}
return mapUser(user);
}
getMe(auth: AuthDto): Promise<UserResponseDto> {
return this.findOrFail(auth.user.id, {}).then(mapUser);
}
async create(dto: CreateUserDto): Promise<UserResponseDto> {
const { memoriesEnabled, notify, ...rest } = dto;
let user = await this.userCore.createUser(rest);
// TODO remove and replace with entire dto.preferences config
if (memoriesEnabled === false) {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: { memories: { enabled: false } },
});
user = await this.findOrFail(user.id, {});
}
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUser(user);
}
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {
const user = await this.findOrFail(dto.id, {});
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
await this.userRepository.syncUsage(dto.id);
}
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
@@ -101,42 +58,40 @@ export class UserService {
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(dto.id, {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences),
});
}
const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto);
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== user.id) {
throw new BadRequestException('Email already in use by another account');
}
}
return mapUser(updatedUser);
const update: Partial<UserEntity> = {
email: dto.email,
name: dto.name,
};
if (dto.password) {
const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
update.password = hashedPassword;
update.shouldChangePassword = false;
}
const updatedUser = await this.userRepository.update(user.id, update);
return mapUserAdmin(updatedUser);
}
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
async get(id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false });
return mapUser(user);
}
async restore(auth: AuthDto, id: string): Promise<UserResponseDto> {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser);
}
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });
+87 -15
View File
@@ -3,22 +3,84 @@ import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AudioStreamInfo,
BitrateDistribution,
TranscodeOptions,
TranscodeCommand,
VideoCodecHWConfig,
VideoCodecSWConfig,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
class BaseConfig implements VideoCodecSWConfig {
presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
constructor(protected config: SystemConfigFFmpegDto) {}
export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor(protected config: SystemConfigFFmpegDto) {}
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config, devices, hasMaliOpenCL);
}
private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) {
case VideoCodec.H264: {
return new H264Config(config);
}
case VideoCodec.HEVC: {
return new HEVCConfig(config);
}
case VideoCodec.VP9: {
return new VP9Config(config);
}
case VideoCodec.AV1: {
return new AV1Config(config);
}
default: {
throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`);
}
}
}
private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) {
let handler: VideoCodecHWConfig;
switch (config.accel) {
case TranscodeHWAccel.NVENC: {
handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
break;
}
case TranscodeHWAccel.QSV: {
handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices);
break;
}
case TranscodeHWAccel.VAAPI: {
handler = new VAAPIConfig(config, devices);
break;
}
case TranscodeHWAccel.RKMPP: {
handler =
config.accelDecode && hasMaliOpenCL
? new RkmppHwDecodeConfig(config, devices)
: new RkmppSwDecodeConfig(config, devices);
break;
}
default: {
throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
}
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new Error(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
);
}
return handler;
}
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = {
inputOptions: this.getBaseInputOptions(videoStream),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions;
} as TranscodeCommand;
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) {
@@ -318,11 +380,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
}
export class ThumbnailConfig extends BaseConfig {
getBaseInputOptions(): string[] {
return ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'];
static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig {
return new ThumbnailConfig(config);
}
getBaseInputOptions(): string[] {
return ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'];
}
getBaseOutputOptions() {
return ['-frames:v 1'];
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
return ['fps=12', 'thumbnail=12', `select=gt(scene\\,0.1)+gt(n\\,20)`, ...super.getFilterOptions(videoStream)];
}
getPresetOptions() {
@@ -338,8 +409,7 @@ export class ThumbnailConfig extends BaseConfig {
}
getScaling(videoStream: VideoStreamInfo) {
let options = super.getScaling(videoStream);
options += ':flags=lanczos+accurate_rnd+bitexact+full_chroma_int';
let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int';
if (!this.shouldToneMap(videoStream)) {
options += ':out_color_matrix=601:out_range=pc';
}
@@ -534,8 +604,6 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
options.push(...this.getToneMapping(videoStream));
if (options.length > 0) {
options[options.length - 1] += ':format=nv12';
} else {
options.push('format=nv12');
}
return options;
}
@@ -559,7 +627,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
}
getInputThreadOptions() {
return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`];
return [`-threads 1`];
}
getOutputThreadOptions() {
@@ -649,7 +717,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
throw new Error('No QSV device found');
}
const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', '-threads 1'];
const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', ...this.getInputThreadOptions()];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
@@ -694,6 +762,10 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
'hwmap=derive_device=qsv:reverse=1,format=qsv',
];
}
getInputThreadOptions() {
return [`-threads 1`];
}
}
export class VAAPIConfig extends BaseHWConfig {
+1 -1
View File
@@ -154,7 +154,7 @@ export function validateCronExpression(expression: string) {
type IValue = { value: string };
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
export const toEmail = ({ value }: IValue) => (value ? value.toLowerCase() : value);
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));