feat: Backend implementation for source plugin usage and configuration

This commit is contained in:
Aleksi Lassila
2024-12-05 19:33:18 +02:00
parent ffc4197832
commit 9b6ff3379e
39 changed files with 2244 additions and 1006 deletions

View File

@@ -0,0 +1,31 @@
import {
ApiProperty,
IntersectionType,
OmitType,
PartialType,
PickType,
} from '@nestjs/swagger';
import { MediaSource } from './user-source.entity';
import { Type } from '@nestjs/common';
const PickAndPartial = <T, K extends keyof T>(
clazz: Type<T>,
pick: K[] = [],
partial: K[] = [],
) =>
IntersectionType(
OmitType(PickType(clazz, pick), partial),
PickType(PartialType(clazz), partial),
);
export class SourceDto extends PickAndPartial(
MediaSource,
['id', 'userId', 'adminControlled', 'enabled'],
['pluginSettings'],
) {}
export class CreateSourceDto extends PickAndPartial(
MediaSource,
['pluginSettings', 'id'],
['enabled', 'adminControlled'],
) {}

View File

@@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { PluginSettings } from 'plugins/plugin-types';
import { User } from 'src/users/user.entity';
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
@Entity()
export class MediaSource {
@ApiProperty({ required: true, type: 'string' })
@PrimaryColumn()
id: string;
@PrimaryColumn()
userId: string;
@ApiProperty({ required: true, type: 'string' })
@ManyToOne(() => User, (user) => user.mediaSources)
@JoinColumn({ name: 'userId' })
user: User;
@ApiProperty({ required: false, type: 'string', default: true })
@Column({ default: true })
enabled: boolean;
@ApiProperty({ required: false, type: 'boolean', default: false })
@Column({ default: false })
adminControlled: boolean;
@ApiProperty({ required: false, type: 'object' })
@Column('json', { default: '{}' })
pluginSettings: PluginSettings = {};
// Add other fields as necessary
}

View File

@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
InternalServerErrorException,
NotFoundException,
Param,
Put,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard, GetUser } from 'src/auth/auth.guard';
import { UsersService } from '../users.service';
import { User } from '../user.entity';
import { UserDto } from '../user.dto';
import { CreateSourceDto } from './user-source.dto';
import {
UserSourcesService,
UserSourcesServiceError,
} from './user-sources.service';
@ApiTags('users')
@Controller('users/:userId/sources')
@UseGuards(AuthGuard)
export class UsersSourcesController {
constructor(
private usersService: UsersService,
private userSourcesService: UserSourcesService,
) {}
@Put(':sourceId')
@ApiOkResponse({ description: 'Source updated', type: UserDto })
async updateSource(
@GetUser() callerUser: User,
@Param('sourceId') sourceId: string,
@Param('userId') userId: string,
@Body() sourceDto: CreateSourceDto,
): Promise<UserDto> {
const user = await this.usersService.findOne(userId);
if (!user) {
throw new NotFoundException('User not found');
}
const updatedUser = await this.userSourcesService
.updateUserSource(user, sourceId, sourceDto, callerUser)
.catch((e) => {
if (e === UserSourcesServiceError.Unauthorized) {
throw new UnauthorizedException();
} else {
throw new InternalServerErrorException('Failed to update source');
}
});
if (!updatedUser) {
throw new InternalServerErrorException('Failed to update source');
}
return UserDto.fromEntity(updatedUser);
}
@Delete(':sourceId')
@ApiOkResponse({ description: 'Source deleted', type: UserDto })
async deleteSource(
@GetUser() callerUser: User,
@Param('sourceId') sourceId: string,
@Param('userId') userId: string,
): Promise<UserDto> {
const updatedUser = await this.userSourcesService.deleteUserSource(userId, sourceId, callerUser);
return UserDto.fromEntity(updatedUser);
}
}

View File

@@ -0,0 +1,96 @@
import { Inject, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { User } from '../user.entity';
import { CreateSourceDto } from './user-source.dto';
import { USER_REPOSITORY, USER_SOURCE_REPOSITORY } from '../user.providers';
import { MediaSource } from './user-source.entity';
import { UsersService } from '../users.service';
export enum UserSourcesServiceError {
SourceNotFound = 'SourceNotFound',
Unauthorized = 'Unauthorized',
}
@Injectable()
export class UserSourcesService {
constructor(
private readonly userService: UsersService,
@Inject(USER_SOURCE_REPOSITORY)
private readonly userSourceRepository: Repository<MediaSource>,
) {}
private async findUserSource(
userId: string,
sourceId: string,
): Promise<MediaSource> {
const source = await this.userSourceRepository.findOne({
where: {
id: sourceId,
userId: userId,
},
});
return source;
}
async deleteUserSource(
userId: string,
sourceId: string,
callerUser: User,
): Promise<User> {
if (!callerUser.isAdmin || callerUser.id !== userId) {
throw UserSourcesServiceError.Unauthorized;
}
const source = await this.findUserSource(userId, sourceId);
if (!source) {
throw UserSourcesServiceError.SourceNotFound;
}
await this.userSourceRepository.remove(source);
return this.userService.findOne(userId);
}
async updateUserSource(
user: User,
sourceId: string,
sourceDto: CreateSourceDto,
callerUser: User = user,
): Promise<User> {
if (!callerUser.isAdmin || callerUser.id !== user.id) {
throw UserSourcesServiceError.Unauthorized;
}
let source = await this.findUserSource(user.id, sourceId);
// Create new if doesn't exist
if (!source) {
source = new MediaSource();
source.user = user;
source.id = sourceId;
source.adminControlled = false;
}
// Check for unauthorized access
if (source.adminControlled && !callerUser.isAdmin) {
throw UserSourcesServiceError.Unauthorized;
} else if (sourceDto.adminControlled !== undefined && !callerUser.isAdmin) {
throw UserSourcesServiceError.Unauthorized;
}
source.adminControlled =
sourceDto.adminControlled ?? source.adminControlled;
source.enabled = sourceDto.enabled ?? source.enabled;
console.log('Test defaults, enabled', new MediaSource().enabled);
source.pluginSettings = sourceDto.pluginSettings ?? source.pluginSettings;
await this.userSourceRepository.save(source);
return this.userService.findOne(user.id);
}
getSourceSettings(user: User, sourceId: string) {
return user.mediaSources?.find((source) => source.id === sourceId)
?.pluginSettings;
}
}

View File

@@ -8,7 +8,7 @@ export class UserDto extends OmitType(User, [
@ApiProperty({ type: 'string' })
profilePicture: string | null;
static fromEntity(entity: User): UserDto {
static fromEntity(entity: User, caller: User = entity): UserDto {
return {
id: entity.id,
name: entity.name,
@@ -17,6 +17,8 @@ export class UserDto extends OmitType(User, [
onboardingDone: entity.onboardingDone,
profilePicture:
'data:image;base64,' + entity.profilePicture?.toString('base64'),
// pluginSettings: entity.pluginSettings,
mediaSources: entity.mediaSources,
};
}
}
@@ -37,6 +39,7 @@ export class UpdateUserDto extends PartialType(
'name',
'password',
'isAdmin',
// 'pluginSettings',
] as const),
) {
@ApiProperty({ type: 'string', required: false })

View File

@@ -1,5 +1,13 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
ManyToOne,
OneToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { MediaSource } from 'src/users/user-sources/user-source.entity';
export class SonarrSettings {
@ApiProperty({ required: true })
@@ -127,4 +135,11 @@ export class User {
@ApiProperty({ required: true, type: Settings })
@Column('json', { default: JSON.stringify(DEFAULT_SETTINGS) })
settings = DEFAULT_SETTINGS;
// @ApiProperty({ required: false, type: 'object' })
// @Column('json', { default: '{}' })
// pluginSettings: PluginSettings = {};
@OneToMany(() => MediaSource, (mediaSource) => mediaSource.user)
mediaSources: MediaSource[];
}

View File

@@ -1,8 +1,10 @@
import { DataSource } from 'typeorm';
import { User } from './user.entity';
import { DATA_SOURCE } from '../database/database.providers';
import { MediaSource } from './user-sources/user-source.entity';
export const USER_REPOSITORY = 'USER_REPOSITORY';
export const USER_SOURCE_REPOSITORY = 'USER_SOURCE_REPOSITORY';
export const userProviders = [
{
@@ -10,4 +12,10 @@ export const userProviders = [
useFactory: (dataSource: DataSource) => dataSource.getRepository(User),
inject: [DATA_SOURCE],
},
{
provide: USER_SOURCE_REPOSITORY,
useFactory: (dataSource: DataSource) =>
dataSource.getRepository(MediaSource),
inject: [DATA_SOURCE],
},
];

View File

@@ -47,7 +47,7 @@ export class UsersController {
type: UserDto,
isArray: true,
})
async findAll(@GetUser() callerUser: User): Promise<UserDto[]> {
async findAllUsers(@GetUser() callerUser: User): Promise<UserDto[]> {
if (!callerUser.isAdmin) {
throw new UnauthorizedException();
}
@@ -61,7 +61,7 @@ export class UsersController {
@Get(':id')
@ApiOkResponse({ description: 'User found', type: UserDto })
@ApiException(() => NotFoundException, { description: 'User not found' })
async findById(
async findUserById(
@Param('id') id: string,
@GetUser() callerUser: User,
): Promise<UserDto> {
@@ -90,7 +90,7 @@ export class UsersController {
@ApiOkResponse({ description: 'User created', type: UserDto })
@ApiException(() => UnauthorizedException, { description: 'Unauthorized' })
@ApiException(() => BadRequestException)
async create(
async createUser(
@Body()
userCreateDto: CreateUserDto,
@GetUser() callerUser: User | undefined,
@@ -113,7 +113,7 @@ export class UsersController {
@Put(':id')
@ApiOkResponse({ description: 'User updated', type: UserDto })
@ApiException(() => NotFoundException, { description: 'User not found' })
async update(
async updateUser(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
@GetUser() callerUser: User,
@@ -121,6 +121,7 @@ export class UsersController {
if ((!callerUser.isAdmin && callerUser.id !== id) || !id) {
throw new NotFoundException();
}
const user = await this.usersService.findOne(id);
const updated = await this.usersService

View File

@@ -5,11 +5,12 @@ import { UsersController } from './users.controller';
import { DatabaseModule } from '../database/database.module';
import { LibraryModule } from './library/library.module';
import { PlayStateModule } from './play-state/play-state.module';
import { UserSourcesService } from './user-sources/user-sources.service';
@Module({
imports: [DatabaseModule, LibraryModule, PlayStateModule],
providers: [...userProviders, UsersService],
providers: [...userProviders, UsersService, UserSourcesService],
controllers: [UsersController],
exports: [UsersService],
exports: [UsersService, UserSourcesService],
})
export class UsersModule {}

View File

@@ -16,18 +16,34 @@ export class UsersService {
private readonly userRepository: Repository<User>,
) {}
// Finds
async findAll(): Promise<User[]> {
return this.userRepository.find();
return this.userRepository.find({
relations: {
mediaSources: true,
},
});
}
async findOne(id: string): Promise<User> {
return this.userRepository.findOne({ where: { id } });
return this.userRepository.findOne({
where: { id },
relations: {
mediaSources: true,
},
});
}
async findOneByName(name: string): Promise<User> {
return this.userRepository.findOne({ where: { name } });
return this.userRepository.findOne({
where: { name },
relations: {
mediaSources: true,
},
});
}
// The rest
async create(userCreateDto: CreateUserDto): Promise<User> {
if (!userCreateDto.name) throw UserServiceError.UsernameRequired;
@@ -46,7 +62,8 @@ export class UsersService {
console.error(e);
}
return this.userRepository.save(user);
await this.userRepository.save(user);
return this.findOne(user.id);
}
async update(
@@ -60,15 +77,24 @@ export class UsersService {
if (
updateUserDto.password !== undefined &&
updateUserDto.oldPassword !== user.password
)
) {
throw UserServiceError.PasswordMismatch;
else if (updateUserDto.password !== undefined)
} else if (updateUserDto.password !== undefined) {
user.password = updateUserDto.password;
}
}
if (updateUserDto.settings) user.settings = updateUserDto.settings;
// if (updateUserDto.pluginSettings) {
// for (const key of Object.keys(updateUserDto.pluginSettings)) {
// user.pluginSettings[key] = updateUserDto.pluginSettings[key];
// }
// }
if (updateUserDto.onboardingDone)
user.onboardingDone = updateUserDto.onboardingDone;
if (updateUserDto.profilePicture) {
try {
user.profilePicture = Buffer.from(
@@ -79,14 +105,17 @@ export class UsersService {
console.error(e);
}
}
if (updateUserDto.isAdmin !== undefined && callerUser.isAdmin)
user.isAdmin = updateUserDto.isAdmin;
return this.userRepository.save(user);
await this.userRepository.save(user);
return this.findOne(user.id);
}
async remove(id: string): Promise<void> {
await this.userRepository.delete(id);
async remove(id: string) {
return await this.userRepository.delete(id);
}
async noPreviousAdmins(): Promise<boolean> {