diff --git a/backend/package.json b/backend/package.json index 95c6fd4..b90f31c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@nestjs/common": "^8.4.0", + "@nestjs/config": "^1.2.0", "@nestjs/core": "^8.4.0", "@nestjs/jwt": "^8.0.0", "@nestjs/passport": "^8.2.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a32d426..a8cf605 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,30 +1,24 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './routes/api/auth/auth.module'; import { ImageModule } from './routes/image/imageroute.module'; import { ServeStaticModule } from '@nestjs/serve-static'; -import Config from './env'; import { DemoManagerModule } from './managers/demo/demomanager.module'; -import { EImageBackend } from './models/entities/image.entity'; -import { EUserBackend } from './models/entities/user.entity'; import { PrefModule } from './routes/api/pref/pref.module'; -import { ESysPreferenceBackend } from './models/entities/syspreference.entity'; +import { TypeOrmConfigService } from './config/typeorm.config.service'; +import { PicsurConfigModule } from './config/config.module'; +import { ServeStaticConfigService } from './config/servestatic.config.service'; + @Module({ imports: [ - TypeOrmModule.forRoot({ - type: 'postgres', - host: Config.database.host, - port: Config.database.port, - username: Config.database.username, - password: Config.database.password, - database: Config.database.database, - synchronize: true, - - entities: [EUserBackend, EImageBackend, ESysPreferenceBackend], + TypeOrmModule.forRootAsync({ + useExisting: TypeOrmConfigService, + imports: [PicsurConfigModule], }), - ServeStaticModule.forRoot({ - rootPath: Config.static.frontendRoot, + ServeStaticModule.forRootAsync({ + useExisting: ServeStaticConfigService, + imports: [PicsurConfigModule], }), AuthModule, ImageModule, diff --git a/backend/src/collections/syspreferencesdb/syspreferencedb.module.ts b/backend/src/collections/syspreferencesdb/syspreferencedb.module.ts index d53fb98..daba7f3 100644 --- a/backend/src/collections/syspreferencesdb/syspreferencedb.module.ts +++ b/backend/src/collections/syspreferencesdb/syspreferencedb.module.ts @@ -1,11 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PicsurConfigModule } from '../../config/config.module'; import { ESysPreferenceBackend } from '../../models/entities/syspreference.entity'; import { SysPreferenceService } from './syspreferencedb.service'; +import { SysPreferenceDefaultsService } from './syspreferencedefaults.service'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [TypeOrmModule.forFeature([ESysPreferenceBackend])], - providers: [SysPreferenceService], + imports: [ + TypeOrmModule.forFeature([ESysPreferenceBackend]), + PicsurConfigModule, + ], + providers: [SysPreferenceService, SysPreferenceDefaultsService], exports: [SysPreferenceService], }) export class SysPreferenceModule {} diff --git a/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts b/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts index e9ae7b5..cdf53ec 100644 --- a/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts +++ b/backend/src/collections/syspreferencesdb/syspreferencedb.service.ts @@ -5,8 +5,8 @@ import { validate } from 'class-validator'; import { SysPreferences } from 'picsur-shared/dist/dto/syspreferences.dto'; import { AsyncFailable, Fail, HasFailed } from 'picsur-shared/dist/types'; import { Repository } from 'typeorm'; -import { SysPreferenceDefaults } from '../../models/dto/syspreference.dto'; import { ESysPreferenceBackend } from '../../models/entities/syspreference.entity'; +import { SysPreferenceDefaultsService } from './syspreferencedefaults.service'; @Injectable() export class SysPreferenceService { @@ -15,10 +15,11 @@ export class SysPreferenceService { constructor( @InjectRepository(ESysPreferenceBackend) private sysPreferenceRepository: Repository, + private defaultsService: SysPreferenceDefaultsService, ) {} public async setPreference( - key: string, + key: SysPreferences, value: string, ): AsyncFailable { let sysPreference = await this.validatePref(key, value); @@ -37,16 +38,17 @@ export class SysPreferenceService { } public async getPreference( - key: string, + key: SysPreferences, ): AsyncFailable { let sysPreference = await this.validatePref(key); if (HasFailed(sysPreference)) return sysPreference; let foundSysPreference: ESysPreferenceBackend | undefined; try { - foundSysPreference = await this.sysPreferenceRepository.findOne({ - key: sysPreference.key, - }); + foundSysPreference = await this.sysPreferenceRepository.findOne( + { key: sysPreference.key }, + { cache: 60000 }, + ); } catch (e: any) { this.logger.warn(e); return Fail('Could not get preference'); @@ -55,7 +57,10 @@ export class SysPreferenceService { if (!foundSysPreference) { return this.saveDefault(sysPreference.key); } else { - foundSysPreference = plainToClass(ESysPreferenceBackend, foundSysPreference); + foundSysPreference = plainToClass( + ESysPreferenceBackend, + foundSysPreference, + ); const errors = await validate(foundSysPreference); if (errors.length > 0) { this.logger.warn(errors); @@ -69,7 +74,7 @@ export class SysPreferenceService { private async saveDefault( key: SysPreferences, ): AsyncFailable { - return this.setPreference(key, SysPreferenceDefaults[key]()); + return this.setPreference(key, this.defaultsService.defaults[key]()); } private async validatePref( diff --git a/backend/src/collections/syspreferencesdb/syspreferencedefaults.service.ts b/backend/src/collections/syspreferencesdb/syspreferencedefaults.service.ts new file mode 100644 index 0000000..2379337 --- /dev/null +++ b/backend/src/collections/syspreferencesdb/syspreferencedefaults.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SysPreferences } from 'picsur-shared/dist/dto/syspreferences.dto'; +import { generateRandomString } from 'picsur-shared/dist/util/random'; +import { EnvJwtConfigService } from '../../config/jwt.config.service'; + +@Injectable() +export class SysPreferenceDefaultsService { + private readonly logger = new Logger('SysPreferenceDefaultsService'); + + constructor(private jwtConfigService: EnvJwtConfigService) {} + + public readonly defaults: { + [key in SysPreferences]: () => string; + } = { + jwt_secret: () => { + const envSecret = this.jwtConfigService.getJwtSecret(); + if (envSecret) { + return envSecret; + } else { + this.logger.warn( + 'Since no JWT secret was provided, a random one will be generated and saved', + ); + return generateRandomString(64); + } + }, + jwt_expires_in: () => this.jwtConfigService.getJwtExpiresIn() ?? '7d', + }; +} diff --git a/backend/src/config/auth.config.service.ts b/backend/src/config/auth.config.service.ts new file mode 100644 index 0000000..705826e --- /dev/null +++ b/backend/src/config/auth.config.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AuthConfigService { + constructor(private configService: ConfigService) {} + + public getDefaultAdminPassword(): string { + return this.configService.get('DEFAULT_ADMIN_PASSWORD', 'admin'); + } + + public getDefaultAdminUsername(): string { + return this.configService.get('DEFAULT_ADMIN_USERNAME', 'admin'); + } +} diff --git a/backend/src/config/config.module.ts b/backend/src/config/config.module.ts new file mode 100644 index 0000000..cc09b3c --- /dev/null +++ b/backend/src/config/config.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmConfigService } from './typeorm.config.service'; +import { ServeStaticConfigService } from './servestatic.config.service'; +import { HostConfigService } from './host.config.service'; +import { EnvJwtConfigService } from './jwt.config.service'; +import { AuthConfigService } from './auth.config.service'; +import { MultipartConfigService } from './multipart.config.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvVars: true, + cache: true, + }), + ], + providers: [ + EnvJwtConfigService, + TypeOrmConfigService, + ServeStaticConfigService, + HostConfigService, + AuthConfigService, + MultipartConfigService, + ], + exports: [ + ConfigModule, + EnvJwtConfigService, + TypeOrmConfigService, + ServeStaticConfigService, + HostConfigService, + AuthConfigService, + MultipartConfigService, + ], +}) +export class PicsurConfigModule {} diff --git a/backend/src/config/config.static.ts b/backend/src/config/config.static.ts new file mode 100644 index 0000000..ecc7157 --- /dev/null +++ b/backend/src/config/config.static.ts @@ -0,0 +1,10 @@ +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +export const EnvPrefix = 'PICSUR_'; +export const DefaultName = 'picsur'; + +export const PackageRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../', +); diff --git a/backend/src/config/host.config.service.ts b/backend/src/config/host.config.service.ts new file mode 100644 index 0000000..be67e7f --- /dev/null +++ b/backend/src/config/host.config.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + ServeStaticModuleOptions, + ServeStaticModuleOptionsFactory, +} from '@nestjs/serve-static'; +import { join } from 'path'; +import { EnvPrefix, PackageRoot } from './config.static'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class HostConfigService { + private readonly logger = new Logger('HostConfigService'); + + constructor(private configService: ConfigService) {} + + public getHost(): string { + const host = this.configService.get(`${EnvPrefix}HOST`, '0.0.0.0'); + this.logger.debug('Host: ' + host); + return host; + } + + public getPort(): number { + const port = this.configService.get(`${EnvPrefix}PORT`, 8080); + this.logger.debug('Port: ' + port); + return port; + } + + public isDemo() { + const enabled = this.configService.get(`${EnvPrefix}_DEMO`, false); + this.logger.debug('Demo enabled: ' + enabled); + return enabled; + } + + public getDemoInterval() { + const interval = this.configService.get( + `${EnvPrefix}_DEMO_INTERVAL`, + 1000 * 60 * 5, + ); + this.logger.debug('Demo interval: ' + interval); + return interval; + } +} diff --git a/backend/src/config/jwt.config.service.ts b/backend/src/config/jwt.config.service.ts new file mode 100644 index 0000000..10baf5c --- /dev/null +++ b/backend/src/config/jwt.config.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EnvJwtConfigService { + constructor(private configService: ConfigService) {} + + public getJwtSecret(): string | undefined { + return this.configService.get('JWT_SECRET'); + } + + public getJwtExpiresIn(): string | undefined { + return this.configService.get('JWT_EXPIRES_IN'); + } +} diff --git a/backend/src/config/jwt.lateconfig.service.ts b/backend/src/config/jwt.lateconfig.service.ts new file mode 100644 index 0000000..1a21c19 --- /dev/null +++ b/backend/src/config/jwt.lateconfig.service.ts @@ -0,0 +1,48 @@ +import { FactoryProvider, Injectable, Logger } from '@nestjs/common'; +import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { SysPreferenceService } from '../collections/syspreferencesdb/syspreferencedb.service'; + +@Injectable() +export class JwtConfigService implements JwtOptionsFactory { + private readonly logger = new Logger('JwtConfigService'); + + constructor(private prefService: SysPreferenceService) {} + + public async getJwtSecret(): Promise { + const secret = await this.prefService.getPreference('jwt_secret'); + if (HasFailed(secret)) { + throw new Error('JWT secret could not be retrieved'); + } + + this.logger.debug('JWT secret: ' + secret.value); + return secret.value; + } + + public async getJwtExpiresIn(): Promise { + const expiresIn = await this.prefService.getPreference('jwt_expires_in'); + if (HasFailed(expiresIn)) { + throw new Error('JWT expiresIn could not be retrieved'); + } + + this.logger.debug('JWT expiresIn: ' + expiresIn.value); + return expiresIn.value; + } + + public async createJwtOptions(): Promise { + return { + secret: await this.getJwtSecret(), + signOptions: { + expiresIn: await this.getJwtExpiresIn(), + }, + }; + } +} + +export const JwtSecretProvider: FactoryProvider> = { + provide: 'JWT_SECRET', + useFactory: async (jwtConfigService: JwtConfigService) => { + return await jwtConfigService.getJwtSecret(); + }, + inject: [JwtConfigService], +}; diff --git a/backend/src/config/lateconfig.module.ts b/backend/src/config/lateconfig.module.ts new file mode 100644 index 0000000..53279bc --- /dev/null +++ b/backend/src/config/lateconfig.module.ts @@ -0,0 +1,40 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { JwtConfigService } from './jwt.lateconfig.service'; +import { SysPreferenceModule } from '../collections/syspreferencesdb/syspreferencedb.module'; +import { PicsurConfigModule } from './config.module'; +import { EnvJwtConfigService } from './jwt.config.service'; +import { SysPreferenceService } from '../collections/syspreferencesdb/syspreferencedb.service'; + +// This module contains all configservices that depend on the syspref module +// The syspref module can only be used when connected to the database +// Since the syspref module requires the database config, we need this seperate +// Otherwise we will create a circular depedency + +@Module({ + imports: [SysPreferenceModule, PicsurConfigModule], + providers: [JwtConfigService], + exports: [JwtConfigService, PicsurConfigModule], +}) +export class PicsurLateConfigModule implements OnModuleInit { + private readonly logger = new Logger('PicsurLateConfigModule'); + + constructor( + private envJwtConfigService: EnvJwtConfigService, + private prefService: SysPreferenceService, + ) {} + + async onModuleInit() { + const secret = this.envJwtConfigService.getJwtSecret(); + const expiresIn = this.envJwtConfigService.getJwtExpiresIn(); + + if (secret === undefined) { + await this.prefService.getPreference('jwt_secret'); + } else { + await this.prefService.setPreference('jwt_secret', secret); + } + + if (expiresIn !== undefined) { + await this.prefService.setPreference('jwt_expires_in', expiresIn); + } + } +} diff --git a/backend/src/config/multipart.config.service.ts b/backend/src/config/multipart.config.service.ts new file mode 100644 index 0000000..3d442f7 --- /dev/null +++ b/backend/src/config/multipart.config.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EnvPrefix } from './config.static'; + +@Injectable() +export class MultipartConfigService { + constructor(private configService: ConfigService) {} + + public getMaxFileSize(): number { + return this.configService.get( + `${EnvPrefix}MAX_FILE_SIZE`, + 128000000, + ); + } + + public getLimits() { + return { + fieldNameSize: 128, + fieldSize: 1024, + fields: 16, + files: 16, + fileSize: this.getMaxFileSize(), + }; + } +} diff --git a/backend/src/config/servestatic.config.service.ts b/backend/src/config/servestatic.config.service.ts new file mode 100644 index 0000000..2f579c8 --- /dev/null +++ b/backend/src/config/servestatic.config.service.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + ServeStaticModuleOptions, + ServeStaticModuleOptionsFactory, +} from '@nestjs/serve-static'; +import { join } from 'path'; +import { EnvPrefix, PackageRoot } from './config.static'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class ServeStaticConfigService + implements ServeStaticModuleOptionsFactory +{ + private readonly logger = new Logger('ServeStaticConfigService'); + + private defaultLocation = join(PackageRoot, '../frontend/dist'); + + constructor(private configService: ConfigService) {} + + public getStaticDirectory(): string { + const directory = this.configService.get( + `${EnvPrefix}STATIC_FRONTEND_ROOT`, + this.defaultLocation, + ); + this.logger.debug('Static directory: ' + directory); + return directory; + } + + public createLoggerOptions(): ServeStaticModuleOptions[] { + return [ + { + rootPath: this.getStaticDirectory(), + }, + ]; + } +} diff --git a/backend/src/config/typeorm.config.service.ts b/backend/src/config/typeorm.config.service.ts new file mode 100644 index 0000000..f4e013f --- /dev/null +++ b/backend/src/config/typeorm.config.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; +import { EntityList } from '../models/entities'; +import { ConfigService } from '@nestjs/config'; +import { DefaultName, EnvPrefix } from './config.static'; + +@Injectable() +export class TypeOrmConfigService implements TypeOrmOptionsFactory { + private readonly logger = new Logger('TypeOrmConfigService'); + + constructor(private configService: ConfigService) {} + + public getTypeOrmServerOptions() { + const varOptions = { + host: this.configService.get(`${EnvPrefix}_DB_HOST`, 'localhost'), + port: this.configService.get(`${EnvPrefix}_DB_PORT`, 5432), + username: this.configService.get( + `${EnvPrefix}_DB_USERNAME`, + DefaultName, + ), + password: this.configService.get( + `${EnvPrefix}_DB_PASSWORD`, + DefaultName, + ), + database: this.configService.get( + `${EnvPrefix}_DB_DATABASE`, + DefaultName, + ), + }; + + this.logger.debug('DB host: ' + varOptions.host); + this.logger.debug('DB port: ' + varOptions.port); + this.logger.debug('DB username: ' + varOptions.username); + this.logger.debug('DB password: ' + varOptions.password); + this.logger.debug('DB database: ' + varOptions.database); + return varOptions; + } + + public createTypeOrmOptions(connectionName?: string): TypeOrmModuleOptions { + this.logger.debug('Creating TypeOrmOptions for: ' + connectionName); + + const varOptions = this.getTypeOrmServerOptions(); + return { + type: 'postgres', + synchronize: true, + + entities: EntityList, + + ...varOptions, + }; + } +} diff --git a/backend/src/decorators/decorator.ts b/backend/src/decorators/decorator.ts new file mode 100644 index 0000000..ffba03c --- /dev/null +++ b/backend/src/decorators/decorator.ts @@ -0,0 +1,22 @@ +import { + createParamDecorator, + ExecutionContext, +} from '@nestjs/common'; +import { Newable } from 'picsur-shared/dist/types'; +import { MultiPartPipe } from './multipart.pipe'; +import { PostFilePipe } from './postfile.pipe'; + +const InjectRequest = createParamDecorator( + async (data: Newable, ctx: ExecutionContext) => { + return { + req: ctx.switchToHttp().getRequest(), + data, + }; + }, +); + +export const PostFile = () => + InjectRequest(PostFilePipe); + +export const MultiPart = (data: Newable) => + InjectRequest(data, MultiPartPipe); diff --git a/backend/src/decorators/decorators.module.ts b/backend/src/decorators/decorators.module.ts new file mode 100644 index 0000000..63719ee --- /dev/null +++ b/backend/src/decorators/decorators.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PicsurConfigModule } from '../config/config.module'; +import { MultiPartPipe } from './multipart.pipe'; +import { PostFilePipe } from './postfile.pipe'; + +@Module({ + imports: [PicsurConfigModule], + providers: [MultiPartPipe, PostFilePipe], + exports: [MultiPartPipe, PostFilePipe, PicsurConfigModule], +}) +export class DecoratorsModule { +} diff --git a/backend/src/decorators/multipart.decorator.ts b/backend/src/decorators/multipart.decorator.ts deleted file mode 100644 index 0491a9e..0000000 --- a/backend/src/decorators/multipart.decorator.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - BadRequestException, - createParamDecorator, - ExecutionContext, - Logger, - Type, -} from '@nestjs/common'; -import { validate } from 'class-validator'; -import { FastifyRequest } from 'fastify'; -import { Multipart, MultipartFields, MultipartFile } from 'fastify-multipart'; -import { Newable } from 'picsur-shared/dist/types'; -import Config from '../env'; -import { MultiPartFieldDto, MultiPartFileDto } from '../models/dto/multipart.dto'; - -const logger = new Logger('MultiPart'); -export interface MPFile { - fieldname: string; -} - -export const PostFile = createParamDecorator( - async (data: unknown, ctx: ExecutionContext) => { - const req: FastifyRequest = ctx.switchToHttp().getRequest(); - - if (!req.isMultipart()) throw new BadRequestException('Invalid file'); - - const file = await req.file({ - limits: { - ...Config.uploadLimits, - files: 1, - }, - }); - if (file === undefined) throw new BadRequestException('Invalid file'); - - const allFields: Multipart[] = Object.values(file.fields).filter( - (entry) => entry, - ) as any; - - const files = allFields.filter((entry) => entry.file !== undefined); - - if (files.length !== 1) throw new BadRequestException('Invalid file'); - - try { - return await files[0].toBuffer(); - } catch (e) { - throw new BadRequestException('Invalid file'); - } - }, -); - -export const MultiPart = createParamDecorator( - async (data: Newable, ctx: ExecutionContext) => { - const req: FastifyRequest = ctx.switchToHttp().getRequest(); - const dtoClass = new data(); - - if (!req.isMultipart()) throw new BadRequestException('Invalid file'); - - let fields: MultipartFields | null = null; - try { - fields = ( - await req.file({ - limits: Config.uploadLimits, - }) - ).fields; - } catch (e) { - logger.warn(e); - } - if (!fields) throw new BadRequestException('Invalid file'); - - for (const key of Object.keys(fields)) { - if (Array.isArray(fields[key])) { - continue; - } - - if ((fields[key] as any).value) { - (dtoClass as any)[key] = new MultiPartFieldDto(fields[key] as MultipartFile); - } else { - (dtoClass as any)[key] = new MultiPartFileDto( - fields[key] as MultipartFile, - new BadRequestException('Invalid file'), - ); - } - } - - const errors = await validate(dtoClass, { forbidUnknownValues: true }); - if (errors.length > 0) { - logger.warn(errors); - throw new BadRequestException('Invalid file'); - } - - return dtoClass; - }, -); diff --git a/backend/src/decorators/multipart.pipe.ts b/backend/src/decorators/multipart.pipe.ts new file mode 100644 index 0000000..478f779 --- /dev/null +++ b/backend/src/decorators/multipart.pipe.ts @@ -0,0 +1,72 @@ +import { + BadRequestException, + Injectable, + Logger, + PipeTransform, + Scope, +} from '@nestjs/common'; +import { validate } from 'class-validator'; +import { FastifyRequest } from 'fastify'; +import { MultipartFields, MultipartFile } from 'fastify-multipart'; +import { Newable } from 'picsur-shared/dist/types'; +import { MultipartConfigService } from '../config/multipart.config.service'; +import { + MultiPartFieldDto, + MultiPartFileDto, +} from '../models/dto/multipart.dto'; + +@Injectable({ scope: Scope.REQUEST }) +export class MultiPartPipe implements PipeTransform { + private readonly logger = new Logger('MultiPartPipe'); + + constructor(private multipartConfigService: MultipartConfigService) {} + + async transform({ + req, + data, + }: { + req: FastifyRequest; + data: Newable; + }) { + const dtoClass = new data(); + + if (!req.isMultipart()) throw new BadRequestException('Invalid file'); + + let fields: MultipartFields | null = null; + try { + fields = ( + await req.file({ + limits: this.multipartConfigService.getLimits(), + }) + ).fields; + } catch (e) { + this.logger.warn(e); + } + if (!fields) throw new BadRequestException('Invalid file'); + + for (const key of Object.keys(fields)) { + if (Array.isArray(fields[key])) { + continue; + } + + if ((fields[key] as any).value) { + (dtoClass as any)[key] = new MultiPartFieldDto( + fields[key] as MultipartFile, + ); + } else { + (dtoClass as any)[key] = new MultiPartFileDto( + fields[key] as MultipartFile, + new BadRequestException('Invalid file'), + ); + } + } + + const errors = await validate(dtoClass, { forbidUnknownValues: true }); + if (errors.length > 0) { + this.logger.warn(errors); + throw new BadRequestException('Invalid file'); + } + + return dtoClass; + } +} diff --git a/backend/src/decorators/postfile.pipe.ts b/backend/src/decorators/postfile.pipe.ts new file mode 100644 index 0000000..f471868 --- /dev/null +++ b/backend/src/decorators/postfile.pipe.ts @@ -0,0 +1,44 @@ +import { + BadRequestException, + Injectable, + Logger, + PipeTransform, + Scope, +} from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; +import { Multipart } from 'fastify-multipart'; +import { MultipartConfigService } from '../config/multipart.config.service'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostFilePipe implements PipeTransform { + private readonly logger = new Logger('PostFilePipe'); + + constructor(private multipartConfigService: MultipartConfigService) {} + + async transform({ req }: { req: FastifyRequest }) { + if (!req.isMultipart()) throw new BadRequestException('Invalid file'); + + const file = await req.file({ + limits: { + ...this.multipartConfigService.getLimits(), + files: 1, + }, + }); + if (file === undefined) throw new BadRequestException('Invalid file'); + + const allFields: Multipart[] = Object.values(file.fields).filter( + (entry) => entry, + ) as any; + + const files = allFields.filter((entry) => entry.file !== undefined); + + if (files.length !== 1) throw new BadRequestException('Invalid file'); + + try { + return await files[0].toBuffer(); + } catch (e) { + this.logger.warn(e); + throw new BadRequestException('Invalid file'); + } + } +} diff --git a/backend/src/env.ts b/backend/src/env.ts deleted file mode 100644 index 65cde10..0000000 --- a/backend/src/env.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { dirname, resolve, join } from 'path'; -import { fileURLToPath } from 'url'; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../'); - -const Config = { - main: { - host: process.env['PICSUR_HOST'] || '0.0.0.0', - port: process.env['PICSUR_PORT'] || 8080, - }, - database: { - host: process.env['PICSUR_DB_HOST'] ?? 'localhost', - port: process.env['PICSUR_DB_PORT'] - ? parseInt(process.env['PICSUR_DB_PORT']) - : 5432, - username: process.env['PICSUR_DB_USERNAME'] ?? 'picsur', - password: process.env['PICSUR_DB_PASSWORD'] ?? 'picsur', - database: process.env['PICSUR_DB_DATABASE'] ?? 'picsur', - }, - defaultAdmin: { - username: process.env['PICSUR_ADMIN_USERNAME'] ?? 'admin', - password: process.env['PICSUR_ADMIN_PASSWORD'] ?? 'admin', - }, - jwt: { - secret: process.env['PICSUR_JWT_SECRET'] ?? 'CHANGE_ME', - expiresIn: process.env['PICSUR_JWT_EXPIRES_IN'] ?? '1d', - }, - uploadLimits: { - fieldNameSize: 128, - fieldSize: 1024, - fields: 16, - files: 16, - fileSize: process.env['PICSUR_MAX_FILE_SIZE'] - ? parseInt(process.env['PICSUR_MAX_FILE_SIZE']) - : 128000000, - }, - static: { - packageRoot: packageRoot, - frontendRoot: - process.env['PICSUR_STATIC_FRONTEND_ROOT'] ?? - join(packageRoot, '../frontend/dist'), - backendRoutes: ['i', 'api'], - }, - demo: { - enabled: process.env['PICSUR_DEMO']?.toLowerCase() === 'true', - interval: process.env['PICSUR_DEMO_INTERVAL'] - ? parseInt(process.env['PICSUR_DEMO_INTERVAL']) - : 1000 * 60 * 5, - }, -}; - -export default Config; diff --git a/backend/src/main.ts b/backend/src/main.ts index 7ad7490..f74266c 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,12 +9,12 @@ import { AppModule } from './app.module'; import * as multipart from 'fastify-multipart'; import { MainExceptionFilter } from './layers/httpexception/httpexception.filter'; import { SuccessInterceptor } from './layers/success/success.interceptor'; -import Config from './env'; +import { HostConfigService } from './config/host.config.service'; async function bootstrap() { const fastifyAdapter = new FastifyAdapter(); - // Todo: generic error messages + // TODO: generic error messages fastifyAdapter.register(multipart as any); const app = await NestFactory.create( @@ -23,8 +23,15 @@ async function bootstrap() { ); app.useGlobalFilters(new MainExceptionFilter()); app.useGlobalInterceptors(new SuccessInterceptor()); - app.useGlobalPipes(new ValidationPipe({ disableErrorMessages: true, forbidUnknownValues: true })); - await app.listen(Config.main.port, Config.main.host); + app.useGlobalPipes( + new ValidationPipe({ + disableErrorMessages: true, + forbidUnknownValues: true, + }), + ); + + const hostConfigService = app.get(HostConfigService); + await app.listen(hostConfigService.getPort(), hostConfigService.getHost()); } bootstrap().catch(console.error); diff --git a/backend/src/managers/demo/demomanager.module.ts b/backend/src/managers/demo/demomanager.module.ts index a4137f3..2737880 100644 --- a/backend/src/managers/demo/demomanager.module.ts +++ b/backend/src/managers/demo/demomanager.module.ts @@ -1,26 +1,30 @@ import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ImageDBModule } from '../../collections/imagedb/imagedb.module'; -import Config from '../../env'; +import { PicsurConfigModule } from '../../config/config.module'; +import { HostConfigService } from '../../config/host.config.service'; import { DemoManagerService } from './demomanager.service'; @Module({ - imports: [ImageDBModule], + imports: [ImageDBModule, PicsurConfigModule], providers: [DemoManagerService], }) export class DemoManagerModule implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger('DemoManagerModule'); - constructor(private readonly demoManagerService: DemoManagerService) {} + constructor( + private readonly demoManagerService: DemoManagerService, + private hostConfigService: HostConfigService, + ) {} private interval: NodeJS.Timeout; onModuleInit() { - if (Config.demo.enabled) { + if (this.hostConfigService.isDemo()) { this.logger.log('Demo mode enabled'); this.interval = setInterval( this.demoManagerService.execute.bind(this.demoManagerService), - Config.demo.interval, + this.hostConfigService.getDemoInterval(), ); } } diff --git a/backend/src/models/dto/syspreference.dto.ts b/backend/src/models/dto/syspreference.dto.ts deleted file mode 100644 index 794f474..0000000 --- a/backend/src/models/dto/syspreference.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SysPreferences } from 'picsur-shared/dist/dto/syspreferences.dto'; -import { generateRandomString } from 'picsur-shared/dist/util/random'; -import Config from '../../env'; - -export const SysPreferenceDefaults: { - [key in SysPreferences]: () => string; -} = { - jwt_secret: () => { - if (Config.jwt.secret !== 'CHANGE_ME') return Config.jwt.secret; - return generateRandomString(32); - }, -}; diff --git a/backend/src/models/entities/index.ts b/backend/src/models/entities/index.ts new file mode 100644 index 0000000..7dedfb8 --- /dev/null +++ b/backend/src/models/entities/index.ts @@ -0,0 +1,5 @@ +import { EImageBackend } from './image.entity'; +import { ESysPreferenceBackend } from './syspreference.entity'; +import { EUserBackend } from './user.entity'; + +export const EntityList = [EImageBackend, EUserBackend, ESysPreferenceBackend]; diff --git a/backend/src/routes/api/auth/auth.module.ts b/backend/src/routes/api/auth/auth.module.ts index 2784548..e4743ae 100644 --- a/backend/src/routes/api/auth/auth.module.ts +++ b/backend/src/routes/api/auth/auth.module.ts @@ -1,8 +1,4 @@ -import { - Logger, - Module, - OnModuleInit, -} from '@nestjs/common'; +import { ExistingProvider, Logger, Module, OnModuleInit } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { AuthService } from './auth.service'; import { LocalAuthStrategy } from './localauth.strategy'; @@ -10,43 +6,49 @@ import { AuthController } from './auth.controller'; import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../../../collections/userdb/userdb.module'; -import Config from '../../../env'; + +import { SysPreferenceModule } from '../../../collections/syspreferencesdb/syspreferencedb.module'; +import { JwtConfigService, JwtSecretProvider } from '../../../config/jwt.lateconfig.service'; +import { PicsurLateConfigModule } from '../../../config/lateconfig.module'; +import { AuthConfigService } from '../../../config/auth.config.service'; @Module({ imports: [ UsersModule, PassportModule, - JwtModule.register({ - secret: Config.jwt.secret, - signOptions: { expiresIn: Config.jwt.expiresIn }, + SysPreferenceModule, + PicsurLateConfigModule, + JwtModule.registerAsync({ + useExisting: JwtConfigService, + imports: [PicsurLateConfigModule], }), ], - providers: [AuthService, LocalAuthStrategy, JwtStrategy], + providers: [ + AuthService, + LocalAuthStrategy, + JwtStrategy, + JwtSecretProvider, + ], controllers: [AuthController], }) export class AuthModule implements OnModuleInit { private readonly logger = new Logger('AuthModule'); - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private authConfigService: AuthConfigService, + ) {} - onModuleInit() { - this.checkJwtSecret(); - this.ensureAdminExists(); - } - - private checkJwtSecret() { - if (Config.jwt.secret === 'CHANGE_ME') { - this.logger.error( - "JWT secret is not set. Please set the 'JWT_SECRET' environment variable.", - ); - } + async onModuleInit() { + await this.ensureAdminExists(); } private async ensureAdminExists() { - const admin = Config.defaultAdmin; - this.logger.debug(`Ensuring admin user ${admin.username} exists`); + const username = this.authConfigService.getDefaultAdminUsername(); + const password = this.authConfigService.getDefaultAdminPassword(); + this.logger.debug(`Ensuring admin user "${username}" exists`); - await this.authService.createUser(admin.username, admin.password); - await this.authService.makeAdmin(admin.username); + await this.authService.createUser(username, password); + await this.authService.makeAdmin(username); } } diff --git a/backend/src/routes/api/auth/jwt.strategy.ts b/backend/src/routes/api/auth/jwt.strategy.ts index a3d7127..4da3606 100644 --- a/backend/src/routes/api/auth/jwt.strategy.ts +++ b/backend/src/routes/api/auth/jwt.strategy.ts @@ -1,21 +1,24 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; -import Config from '../../../env'; import { JwtDataDto } from 'picsur-shared/dist/dto/auth.dto'; import { EUserBackend } from '../../../models/entities/user.entity'; - @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { private readonly logger = new Logger('JwtStrategy'); - constructor() { + constructor(@Inject('JWT_SECRET') private jwtSecret: string) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: Config.jwt.secret, + secretOrKey: jwtSecret, }); } diff --git a/backend/src/routes/api/pref/pref.controller.ts b/backend/src/routes/api/pref/pref.controller.ts index 0e1c816..29e8d7d 100644 --- a/backend/src/routes/api/pref/pref.controller.ts +++ b/backend/src/routes/api/pref/pref.controller.ts @@ -8,7 +8,10 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { UpdateSysPreferenceRequest } from 'picsur-shared/dist/dto/syspreferences.dto'; +import { + SysPreferences, + UpdateSysPreferenceRequest, +} from 'picsur-shared/dist/dto/syspreferences.dto'; import { HasFailed } from 'picsur-shared/dist/types'; import { SysPreferenceService } from '../../../collections/syspreferencesdb/syspreferencedb.service'; import { AdminGuard } from '../auth/admin.guard'; @@ -21,7 +24,9 @@ export class PrefController { @Get('sys/:key') async getSysPref(@Param('key') key: string) { - const returned = await this.prefService.getPreference(key); + const returned = await this.prefService.getPreference( + key as SysPreferences, + ); if (HasFailed(returned)) { console.warn(returned.getReason()); throw new InternalServerErrorException('Could not get preference'); @@ -36,7 +41,10 @@ export class PrefController { @Body() body: UpdateSysPreferenceRequest, ) { const value = body.value; - const returned = await this.prefService.setPreference(key, value); + const returned = await this.prefService.setPreference( + key as SysPreferences, + value, + ); if (HasFailed(returned)) { console.warn(returned.getReason()); throw new InternalServerErrorException('Could not set preference'); diff --git a/backend/src/routes/image/imageroute.controller.ts b/backend/src/routes/image/imageroute.controller.ts index f49f3f1..445231d 100644 --- a/backend/src/routes/image/imageroute.controller.ts +++ b/backend/src/routes/image/imageroute.controller.ts @@ -11,10 +11,10 @@ import { } from '@nestjs/common'; import { FastifyReply, FastifyRequest } from 'fastify'; import { HasFailed } from 'picsur-shared/dist/types'; -import { MultiPart } from '../../decorators/multipart.decorator'; import { ImageManagerService } from '../../managers/imagemanager/imagemanager.service'; import { isHash } from 'class-validator'; import { ImageUploadDto } from '../../models/dto/imageroute.dto'; +import { MultiPart } from '../../decorators/decorator'; @Controller('i') export class ImageController { constructor(private readonly imagesService: ImageManagerService) {} diff --git a/backend/src/routes/image/imageroute.module.ts b/backend/src/routes/image/imageroute.module.ts index 994e776..0133f5b 100644 --- a/backend/src/routes/image/imageroute.module.ts +++ b/backend/src/routes/image/imageroute.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { DecoratorsModule } from '../../decorators/decorators.module'; import { ImageManagerModule } from '../../managers/imagemanager/imagemanager.module'; import { ImageController } from './imageroute.controller'; @Module({ - imports: [ImageManagerModule], + imports: [ImageManagerModule, DecoratorsModule], controllers: [ImageController], }) export class ImageModule {} diff --git a/shared/src/dto/syspreferences.dto.ts b/shared/src/dto/syspreferences.dto.ts index f7f828c..e36e42e 100644 --- a/shared/src/dto/syspreferences.dto.ts +++ b/shared/src/dto/syspreferences.dto.ts @@ -3,7 +3,7 @@ import tuple from '../types/tuple'; import { randomBytes } from 'crypto'; import { IsNotEmpty } from 'class-validator'; -const SysPreferencesTuple = tuple('jwt_secret'); +const SysPreferencesTuple = tuple('jwt_secret', 'jwt_expires_in'); export const SysPreferences: string[] = SysPreferencesTuple; export type SysPreferences = typeof SysPreferencesTuple[number]; diff --git a/yarn.lock b/yarn.lock index 4930757..54ec7a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1409,6 +1409,16 @@ tslib "2.3.1" uuid "8.3.2" +"@nestjs/config@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-1.2.0.tgz#a4eb58390dd8145b761ee0c8e98e78c8471cabb1" + integrity sha512-GGZOj2g6EMZ23orsQqeD2Vs5E2ZrmAiB0qCGvERv+5nQmZjY4nKkisG4awQsym1uotmmzgtsd9lOiKqTIFONhA== + dependencies: + dotenv "16.0.0" + dotenv-expand "8.0.1" + lodash "4.17.21" + uuid "8.3.2" + "@nestjs/core@^8.4.0": version "8.4.0" resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-8.4.0.tgz#f4c1840b9b233e3985407f496b7c1e78e0169c70" @@ -3298,6 +3308,16 @@ domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" +dotenv-expand@8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-8.0.1.tgz#332aa17c14b12e28e2e230f8d183eecc1c014fdc" + integrity sha512-j/Ih7bIERDR5PzI89Zu8ayd3tXZ6E3dbY0ljQ9Db0K87qBO8zdLsi2dIvDHMWtjC3Yxb8XixOTHAtia0fDHRpg== + +dotenv@16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" + integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q== + dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -5164,7 +5184,7 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.21: +lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==