feat: Editing, creating and deleting server accounts, user.controller improvements

This commit is contained in:
Aleksi Lassila
2024-06-16 22:15:47 +03:00
parent 9b5be9e2ae
commit 0107b4f7c3
28 changed files with 601 additions and 332 deletions

View 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) {}

View 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;
}

View 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],
},
];

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

View 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 {}

View 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;
}
}