diff --git a/backend/src/collections/userdb/userdb.service.ts b/backend/src/collections/userdb/userdb.service.ts index 3fffa94..48b05f1 100644 --- a/backend/src/collections/userdb/userdb.service.ts +++ b/backend/src/collections/userdb/userdb.service.ts @@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { plainToClass } from 'class-transformer'; import { validate } from 'class-validator'; +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; import { AsyncFailable, Fail, HasFailed, - HasSuccess, + HasSuccess } from 'picsur-shared/dist/types'; import { Repository } from 'typeorm'; import { EUserBackend } from '../../models/entities/user.entity'; @@ -24,12 +25,14 @@ export class UsersService { public async create( username: string, hashedPassword: string, + roles?: Roles, ): AsyncFailable { if (await this.exists(username)) return Fail('User already exists'); let user = new EUserBackend(); user.username = username; user.password = hashedPassword; + user.roles = ['user', ...(roles || [])]; try { user = await this.usersRepository.save(user, { reload: true }); @@ -41,7 +44,9 @@ export class UsersService { } // Returns user object without id - public async delete(user: string | EUserBackend): AsyncFailable { + public async delete( + user: string | EUserBackend, + ): AsyncFailable { const userToModify = await this.resolve(user); if (HasFailed(userToModify)) return userToModify; @@ -55,7 +60,9 @@ export class UsersService { public async findOne( username: string, getPrivate?: B, - ): AsyncFailable> { + ): AsyncFailable< + B extends undefined ? EUserBackend : Required + > { try { const found = await this.usersRepository.findOne({ where: { username }, @@ -63,7 +70,9 @@ export class UsersService { }); if (!found) return Fail('User not found'); - return found as B extends undefined ? EUserBackend : Required; + return found as B extends undefined + ? EUserBackend + : Required; } catch (e: any) { return Fail(e?.message); } @@ -81,20 +90,48 @@ export class UsersService { return HasSuccess(await this.findOne(username)); } - public async modifyAdmin( + public async addRoles( user: string | EUserBackend, - admin: boolean, + roles: Roles, ): AsyncFailable { const userToModify = await this.resolve(user); if (HasFailed(userToModify)) return userToModify; - userToModify.isAdmin = admin; - await this.usersRepository.save(userToModify); + // This is stupid + userToModify.roles = [...new Set([...userToModify.roles, ...roles])]; + + try { + await this.usersRepository.save(userToModify); + } catch (e: any) { + return Fail(e?.message); + } return true; } - private async resolve(user: string | EUserBackend): AsyncFailable { + public async removeRoles( + user: string | EUserBackend, + roles: Roles, + ): AsyncFailable { + const userToModify = await this.resolve(user); + if (HasFailed(userToModify)) return userToModify; + + userToModify.roles = userToModify.roles.filter( + (role) => !roles.includes(role), + ); + + try { + await this.usersRepository.save(userToModify); + } catch (e: any) { + return Fail(e?.message); + } + + return true; + } + + private async resolve( + user: string | EUserBackend, + ): AsyncFailable { if (typeof user === 'string') { return await this.findOne(user); } else { diff --git a/backend/src/config/typeorm.config.service.ts b/backend/src/config/typeorm.config.service.ts index b0c74e6..a0af791 100644 --- a/backend/src/config/typeorm.config.service.ts +++ b/backend/src/config/typeorm.config.service.ts @@ -3,12 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; import { EntityList } from '../models/entities'; import { DefaultName, EnvPrefix } from './config.static'; +import { HostConfigService } from './host.config.service'; @Injectable() export class TypeOrmConfigService implements TypeOrmOptionsFactory { private readonly logger = new Logger('TypeOrmConfigService'); - constructor(private configService: ConfigService) {} + constructor(private configService: ConfigService, private hostService: HostConfigService) {} public getTypeOrmServerOptions() { const varOptions = { @@ -42,7 +43,7 @@ export class TypeOrmConfigService implements TypeOrmOptionsFactory { const varOptions = this.getTypeOrmServerOptions(); return { type: 'postgres', - synchronize: true, + synchronize: !this.hostService.isProduction(), entities: EntityList, diff --git a/backend/src/main.ts b/backend/src/main.ts index 56ff3dc..aabdb8a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -21,8 +21,8 @@ async function bootstrap() { AppModule, fastifyAdapter, { - bufferLogs: true - } + bufferLogs: true, + }, ); app.useGlobalFilters(new MainExceptionFilter()); app.useGlobalInterceptors(new SuccessInterceptor()); diff --git a/backend/src/managers/auth/auth.module.ts b/backend/src/managers/auth/auth.module.ts index 2dba37a..3f8b149 100644 --- a/backend/src/managers/auth/auth.module.ts +++ b/backend/src/managers/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { HasFailed } from 'picsur-shared/dist/types'; import { SysPreferenceModule } from '../../collections/syspreferencesdb/syspreferencedb.module'; import { UsersModule } from '../../collections/userdb/userdb.module'; import { AuthConfigService } from '../../config/auth.config.service'; @@ -51,7 +52,23 @@ export class AuthManagerModule implements OnModuleInit { const password = this.authConfigService.getDefaultAdminPassword(); this.logger.debug(`Ensuring admin user "${username}" exists`); - await this.authService.createUser(username, password); - await this.authService.makeAdmin(username); + const exists = await this.authService.userExists(username); + if (exists) return; + + const newUser = await this.authService.createUser(username, password); + if (HasFailed(newUser)) { + this.logger.error( + `Failed to create admin user "${username}" because: ${newUser.getReason()}`, + ); + return; + } + + const result = await this.authService.makeAdmin(newUser); + if (HasFailed(result)) { + this.logger.error( + `Failed to make admin user "${username}" because: ${result.getReason()}`, + ); + return; + } } } diff --git a/backend/src/managers/auth/auth.service.ts b/backend/src/managers/auth/auth.service.ts index 87f63f6..05b3ac6 100644 --- a/backend/src/managers/auth/auth.service.ts +++ b/backend/src/managers/auth/auth.service.ts @@ -30,6 +30,10 @@ export class AuthManagerService { return this.usersService.findAll(); } + async userExists(username: string): Promise { + return this.usersService.exists(username); + } + async authenticate(username: string, password: string): AsyncFailable { const user = await this.usersService.findOne(username, true); if (HasFailed(user)) return user; @@ -55,10 +59,10 @@ export class AuthManagerService { } async makeAdmin(user: string | EUserBackend): AsyncFailable { - return this.usersService.modifyAdmin(user, true); + return this.usersService.addRoles(user, ['admin']); } async revokeAdmin(user: string | EUserBackend): AsyncFailable { - return this.usersService.modifyAdmin(user, false); + return this.usersService.removeRoles(user, ['admin']); } } diff --git a/backend/src/managers/auth/guards/admin.guard.ts b/backend/src/managers/auth/guards/admin.guard.ts index e5a5d18..70ab145 100644 --- a/backend/src/managers/auth/guards/admin.guard.ts +++ b/backend/src/managers/auth/guards/admin.guard.ts @@ -26,6 +26,6 @@ export class AdminGuard implements CanActivate { return false; } - return user.isAdmin; + return user.roles.includes('admin'); } } diff --git a/backend/src/models/entities/image.entity.ts b/backend/src/models/entities/image.entity.ts index 9314f60..0878d51 100644 --- a/backend/src/models/entities/image.entity.ts +++ b/backend/src/models/entities/image.entity.ts @@ -11,13 +11,13 @@ export class EImageBackend extends EImage { override id?: number; @Index() - @Column({ unique: true }) + @Column({ unique: true, nullable: false }) override hash: string; // Binary data @Column({ type: 'bytea', nullable: false, select: false }) override data?: Buffer; - @Column({ enum: SupportedMimes }) + @Column({ enum: SupportedMimes, nullable: false }) override mime: SupportedMime; } diff --git a/backend/src/models/entities/syspreference.entity.ts b/backend/src/models/entities/syspreference.entity.ts index 98836d5..3b6d227 100644 --- a/backend/src/models/entities/syspreference.entity.ts +++ b/backend/src/models/entities/syspreference.entity.ts @@ -7,9 +7,9 @@ export class ESysPreferenceBackend extends ESysPreference { @PrimaryGeneratedColumn() override id?: number; - @Column({ unique: true }) + @Column({ nullable: false, unique: true }) override key: SysPreferences; - @Column() + @Column({ nullable: false }) override value: string; } diff --git a/backend/src/models/entities/user.entity.ts b/backend/src/models/entities/user.entity.ts index e55f14a..df7e5c4 100644 --- a/backend/src/models/entities/user.entity.ts +++ b/backend/src/models/entities/user.entity.ts @@ -1,3 +1,4 @@ +import { Roles } from 'picsur-shared/dist/dto/roles.dto'; import { EUser } from 'picsur-shared/dist/entities/user.entity'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; @@ -9,12 +10,12 @@ export class EUserBackend extends EUser { override id?: number; @Index() - @Column({ unique: true }) + @Column({ nullable: false, unique: true }) override username: string; - @Column({ default: false }) - override isAdmin: boolean; + @Column('text', { nullable: false, array: true }) + override roles: Roles; - @Column({ select: false }) + @Column({ nullable: false, select: false }) override password?: string; } diff --git a/shared/src/dto/roles.dto.ts b/shared/src/dto/roles.dto.ts new file mode 100644 index 0000000..3ae6867 --- /dev/null +++ b/shared/src/dto/roles.dto.ts @@ -0,0 +1,12 @@ +import tuple from '../types/tuple'; + +// Config + +const RolesTuple = tuple('user', 'admin'); + +// Derivatives + +export const RolesList: string[] = RolesTuple; + +export type Role = typeof RolesTuple[number]; +export type Roles = Role[]; diff --git a/shared/src/entities/user.entity.ts b/shared/src/entities/user.entity.ts index a1f65ee..f2ece99 100644 --- a/shared/src/entities/user.entity.ts +++ b/shared/src/entities/user.entity.ts @@ -1,9 +1,6 @@ import { Exclude } from 'class-transformer'; -import { - IsDefined, - IsNotEmpty, - IsOptional, -} from 'class-validator'; +import { IsArray, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; +import { Roles, RolesList } from '../dto/roles.dto'; export class EUser { @IsOptional() @@ -12,8 +9,9 @@ export class EUser { @IsNotEmpty() username: string; - @IsDefined() - isAdmin: boolean; + @IsArray() + @IsEnum(RolesList, { each: true }) + roles: Roles; @IsOptional() @Exclude()