mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-27 11:05:13 +02:00
feat: Editing, creating and deleting server accounts, user.controller improvements
This commit is contained in:
49
backend/src/users/user.dto.ts
Normal file
49
backend/src/users/user.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty, OmitType, PartialType, PickType } from '@nestjs/swagger';
|
||||
import { User } from './user.entity';
|
||||
|
||||
export class UserDto extends OmitType(User, [
|
||||
'password',
|
||||
'profilePicture',
|
||||
] as const) {
|
||||
@ApiProperty({ type: 'string' })
|
||||
profilePicture: string | null;
|
||||
|
||||
static fromEntity(entity: User): UserDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
isAdmin: entity.isAdmin,
|
||||
settings: entity.settings,
|
||||
onboardingDone: entity.onboardingDone,
|
||||
profilePicture:
|
||||
'data:image;base64,' + entity.profilePicture?.toString('base64'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CreateUserDto extends PickType(User, [
|
||||
'name',
|
||||
'password',
|
||||
'isAdmin',
|
||||
] as const) {
|
||||
@ApiProperty({ type: 'string', required: false })
|
||||
profilePicture?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto extends PartialType(
|
||||
PickType(User, [
|
||||
'settings',
|
||||
'onboardingDone',
|
||||
'name',
|
||||
'password',
|
||||
'isAdmin',
|
||||
] as const),
|
||||
) {
|
||||
@ApiProperty({ type: 'string', required: false })
|
||||
profilePicture?: string;
|
||||
|
||||
@ApiProperty({ type: 'string', required: false })
|
||||
oldPassword?: string;
|
||||
}
|
||||
|
||||
export class SignInDto extends PickType(User, ['name', 'password'] as const) {}
|
||||
130
backend/src/users/user.entity.ts
Normal file
130
backend/src/users/user.entity.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SonarrSettings {
|
||||
@ApiProperty({ required: true })
|
||||
apiKey: string;
|
||||
@ApiProperty({ required: true })
|
||||
baseUrl: string;
|
||||
@ApiProperty({ required: true })
|
||||
qualityProfileId: number;
|
||||
@ApiProperty({ required: true })
|
||||
rootFolderPath: string;
|
||||
@ApiProperty({ required: true })
|
||||
languageProfileId: number;
|
||||
}
|
||||
|
||||
export class RadarrSettings {
|
||||
@ApiProperty({ required: true })
|
||||
apiKey: string;
|
||||
@ApiProperty({ required: true })
|
||||
baseUrl: string;
|
||||
@ApiProperty({ required: true })
|
||||
qualityProfileId: number;
|
||||
@ApiProperty({ required: true })
|
||||
rootFolderPath: string;
|
||||
}
|
||||
|
||||
export class JellyfinSettings {
|
||||
@ApiProperty({ required: true })
|
||||
apiKey: string;
|
||||
@ApiProperty({ required: true })
|
||||
baseUrl: string;
|
||||
@ApiProperty({ required: true })
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class TmdbSettings {
|
||||
@ApiProperty({ required: true })
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class Settings {
|
||||
@ApiProperty({ required: true })
|
||||
autoplayTrailers: boolean;
|
||||
@ApiProperty({ required: true })
|
||||
language: string;
|
||||
@ApiProperty({ required: true })
|
||||
animationDuration: number;
|
||||
// discover: {
|
||||
// region: string,
|
||||
// excludeLibraryItems: true,
|
||||
// includedLanguages: 'en'
|
||||
// },
|
||||
@ApiProperty({ required: true, type: SonarrSettings })
|
||||
sonarr: SonarrSettings;
|
||||
@ApiProperty({ required: true, type: RadarrSettings })
|
||||
radarr: RadarrSettings;
|
||||
@ApiProperty({ required: true, type: JellyfinSettings })
|
||||
jellyfin: JellyfinSettings;
|
||||
@ApiProperty({ required: true, type: TmdbSettings })
|
||||
tmdb: TmdbSettings;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
autoplayTrailers: true,
|
||||
language: 'en',
|
||||
animationDuration: 300,
|
||||
// discover: {
|
||||
// region: 'US',
|
||||
// excludeLibraryItems: true,
|
||||
// includedLanguages: 'en'
|
||||
// },
|
||||
sonarr: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
qualityProfileId: 0,
|
||||
rootFolderPath: '',
|
||||
languageProfileId: 0,
|
||||
},
|
||||
radarr: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
qualityProfileId: 0,
|
||||
rootFolderPath: '',
|
||||
},
|
||||
jellyfin: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
userId: '',
|
||||
},
|
||||
tmdb: {
|
||||
sessionId: '',
|
||||
userId: '',
|
||||
},
|
||||
};
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@ApiProperty({ required: true })
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
@Column({ unique: true })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@Column({ type: 'blob', nullable: true })
|
||||
profilePicture: Buffer;
|
||||
|
||||
@Column()
|
||||
@ApiProperty({ required: true })
|
||||
@Column({ default: false })
|
||||
isAdmin: boolean = false;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@Column({ default: false })
|
||||
onboardingDone: boolean = false;
|
||||
|
||||
@ApiProperty({ required: true, type: Settings })
|
||||
@Column('json', { default: JSON.stringify(DEFAULT_SETTINGS) })
|
||||
settings = DEFAULT_SETTINGS;
|
||||
}
|
||||
13
backend/src/users/user.providers.ts
Normal file
13
backend/src/users/user.providers.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { DATA_SOURCE } from '../database/database.providers';
|
||||
|
||||
export const USER_REPOSITORY = 'USER_REPOSITORY';
|
||||
|
||||
export const userProviders = [
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useFactory: (dataSource: DataSource) => dataSource.getRepository(User),
|
||||
inject: [DATA_SOURCE],
|
||||
},
|
||||
];
|
||||
149
backend/src/users/users.controller.ts
Normal file
149
backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { UserServiceError, UsersService } from './users.service';
|
||||
import { AuthGuard, GetUser, OptionalAuthGuard } from '../auth/auth.guard';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateUserDto, UpdateUserDto, UserDto } from './user.dto';
|
||||
import { User } from './user.entity';
|
||||
import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
// @UseGuards(AuthGuard)
|
||||
// @Get()
|
||||
// @ApiOkResponse({ description: 'User found', type: UserDto })
|
||||
// @ApiException(() => NotFoundException, { description: 'User not found' })
|
||||
// async getProfile(@GetUser() user: User): Promise<UserDto> {
|
||||
// console.log(user);
|
||||
//
|
||||
// if (!user) {
|
||||
// throw new NotFoundException();
|
||||
// }
|
||||
//
|
||||
// return UserDto.fromEntity(user);
|
||||
// }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('')
|
||||
@ApiOkResponse({
|
||||
description: 'All users found',
|
||||
type: UserDto,
|
||||
isArray: true,
|
||||
})
|
||||
async findAll(@GetUser() callerUser: User): Promise<UserDto[]> {
|
||||
if (!callerUser.isAdmin) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const users = await this.usersService.findAll();
|
||||
|
||||
return users.map((user) => UserDto.fromEntity(user));
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get(':id')
|
||||
@ApiOkResponse({ description: 'User found', type: UserDto })
|
||||
@ApiException(() => NotFoundException, { description: 'User not found' })
|
||||
async findById(
|
||||
@Param('id') id: string,
|
||||
@GetUser() callerUser: User,
|
||||
): Promise<UserDto> {
|
||||
console.log('callerUser', callerUser);
|
||||
if (!callerUser.isAdmin && callerUser.id !== id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const user = await this.usersService.findOne(id);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return UserDto.fromEntity(user);
|
||||
}
|
||||
|
||||
// @Get('isSetupDone')
|
||||
// @ApiOkResponse({ description: 'Setup done', type: Boolean })
|
||||
// async isSetupDone() {
|
||||
// return this.userService.noPreviousAdmins();
|
||||
// }
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post()
|
||||
@ApiOkResponse({ description: 'User created', type: UserDto })
|
||||
@ApiException(() => UnauthorizedException, { description: 'Unauthorized' })
|
||||
@ApiException(() => BadRequestException)
|
||||
async create(
|
||||
@Body()
|
||||
userCreateDto: CreateUserDto,
|
||||
@GetUser() callerUser: User | undefined,
|
||||
) {
|
||||
const canCreateUser =
|
||||
(await this.usersService.noPreviousAdmins()) || callerUser?.isAdmin;
|
||||
|
||||
if (!canCreateUser) throw new UnauthorizedException();
|
||||
|
||||
const user = await this.usersService.create(userCreateDto).catch((e) => {
|
||||
if (e === UserServiceError.UsernameRequired)
|
||||
throw new BadRequestException('Username is required');
|
||||
else throw new InternalServerErrorException();
|
||||
});
|
||||
|
||||
return UserDto.fromEntity(user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOkResponse({ description: 'User updated', type: UserDto })
|
||||
@ApiException(() => NotFoundException, { description: 'User not found' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@GetUser() callerUser: User,
|
||||
): Promise<UserDto> {
|
||||
if ((!callerUser.isAdmin && callerUser.id !== id) || !id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const user = await this.usersService.findOne(id);
|
||||
|
||||
const updated = await this.usersService
|
||||
.update(user, callerUser, updateUserDto)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
if (e === UserServiceError.PasswordMismatch) {
|
||||
throw new BadRequestException('Password mismatch');
|
||||
} else throw new InternalServerErrorException();
|
||||
});
|
||||
|
||||
return UserDto.fromEntity(updated);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete(':id')
|
||||
@ApiOkResponse({ description: 'User deleted' })
|
||||
@ApiException(() => NotFoundException, { description: 'User not found' })
|
||||
async deleteUser(@Param('id') id: string, @GetUser() callerUser: User) {
|
||||
if ((!callerUser.isAdmin && callerUser.id !== id) || !id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.usersService.remove(id);
|
||||
}
|
||||
}
|
||||
13
backend/src/users/users.module.ts
Normal file
13
backend/src/users/users.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { userProviders } from './user.providers';
|
||||
import { UsersController } from './users.controller';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [...userProviders, UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
99
backend/src/users/users.service.ts
Normal file
99
backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { CreateUserDto, UpdateUserDto } from './user.dto';
|
||||
|
||||
export enum UserServiceError {
|
||||
PasswordMismatch = 'PasswordMismatch',
|
||||
Unauthorized = 'Unauthorized',
|
||||
UsernameRequired = 'UsernameRequired',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@Inject('USER_REPOSITORY')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.userRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
return this.userRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findOneByName(name: string): Promise<User> {
|
||||
return this.userRepository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
async create(userCreateDto: CreateUserDto): Promise<User> {
|
||||
if (!userCreateDto.name) throw UserServiceError.UsernameRequired;
|
||||
|
||||
const user = this.userRepository.create();
|
||||
user.name = userCreateDto.name;
|
||||
// TODO: Hash password
|
||||
user.password = userCreateDto.password;
|
||||
user.isAdmin = userCreateDto.isAdmin;
|
||||
|
||||
try {
|
||||
user.profilePicture = Buffer.from(
|
||||
userCreateDto.profilePicture.split(';base64,').pop() as string,
|
||||
'base64',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async update(
|
||||
user: User,
|
||||
callerUser: User,
|
||||
updateUserDto: UpdateUserDto,
|
||||
): Promise<User> {
|
||||
if (updateUserDto.name) user.name = updateUserDto.name;
|
||||
|
||||
if (updateUserDto.oldPassword !== updateUserDto.password) {
|
||||
if (
|
||||
updateUserDto.password !== undefined &&
|
||||
updateUserDto.oldPassword !== user.password
|
||||
)
|
||||
throw UserServiceError.PasswordMismatch;
|
||||
else if (updateUserDto.password !== undefined)
|
||||
user.password = updateUserDto.password;
|
||||
}
|
||||
|
||||
if (updateUserDto.settings) user.settings = updateUserDto.settings;
|
||||
if (updateUserDto.onboardingDone)
|
||||
user.onboardingDone = updateUserDto.onboardingDone;
|
||||
if (updateUserDto.profilePicture) {
|
||||
try {
|
||||
user.profilePicture = Buffer.from(
|
||||
updateUserDto.profilePicture.split(';base64,').pop() as string,
|
||||
'base64',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
if (updateUserDto.isAdmin !== undefined && callerUser.isAdmin)
|
||||
user.isAdmin = updateUserDto.isAdmin;
|
||||
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.userRepository.delete(id);
|
||||
}
|
||||
|
||||
async noPreviousAdmins(): Promise<boolean> {
|
||||
const adminCount = await this.userRepository.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
|
||||
return adminCount === 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user