diff --git a/backend/package.json b/backend/package.json index 9dd0a4b2..f349aa9f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,9 @@ "format": "prettier --write 'src/**/*.ts'", "test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json" }, + "prisma": { + "seed": "ts-node prisma/seed/config.seed.ts" + }, "dependencies": { "@nestjs/common": "^9.1.2", "@nestjs/config": "^2.2.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bb2b8af6..d997f582 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -77,3 +77,14 @@ model ShareSecurity { shareId String? @unique share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade) } + +model Config { + updatedAt DateTime @updatedAt + + key String @id + type String + value String? + default String + secret Boolean @default(true) + locked Boolean @default(false) +} diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts new file mode 100644 index 00000000..269d7fa0 --- /dev/null +++ b/backend/prisma/seed/config.seed.ts @@ -0,0 +1,118 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const configVariables = [ + { + key: "setupFinished", + type: "boolean", + default: "false", + secret: false, + locked: true + }, + { + key: "appUrl", + type: "string", + default: "http://localhost:3000", + secret: false, + }, + { + key: "showHomePage", + type: "boolean", + default: "true", + secret: false, + }, + { + key: "allowRegistration", + type: "boolean", + default: "true", + secret: false, + }, + { + key: "allowUnauthenticatedShares", + type: "boolean", + default: "false", + secret: false, + }, + { + key: "maxFileSize", + type: "number", + default: "1000000000", + secret: false, + }, + { + key: "jwtSecret", + type: "string", + default: "long-random-string", + locked: true + }, + { + key: "emailRecipientsEnabled", + type: "boolean", + default: "false", + secret: false, + }, + { + key: "smtpHost", + type: "string", + default: "", + }, + { + key: "smtpPort", + type: "number", + default: "", + }, + { + key: "smtpEmail", + type: "string", + default: "", + }, + { + key: "smtpPassword", + type: "string", + default: "", + }, +]; + +async function main() { + for (const variable of configVariables) { + const existingConfigVariable = await prisma.config.findUnique({ + where: { key: variable.key }, + }); + + // Create a new config variable if it doesn't exist + if (!existingConfigVariable) { + await prisma.config.create({ + data: variable, + }); + } else { + // Update the config variable if the default value has changed + if (existingConfigVariable.default != variable.default) { + await prisma.config.update({ + where: { key: variable.key }, + data: { default: variable.default }, + }); + } + } + } + + // Delete the config variable if it doesn't exist anymore + const configVariablesFromDatabase = await prisma.config.findMany(); + + for (const configVariableFromDatabase of configVariablesFromDatabase) { + if (!configVariables.find((v) => v.key == configVariableFromDatabase.key)) { + await prisma.config.delete({ + where: { key: configVariableFromDatabase.key }, + }); + } + } +} +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d93252f0..8de599b6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,11 +1,14 @@ import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; + import { ScheduleModule } from "@nestjs/schedule"; import { AuthModule } from "./auth/auth.module"; import { JobsService } from "./jobs/jobs.service"; import { APP_GUARD } from "@nestjs/core"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { ConfigModule } from "./config/config.module"; +import { ConfigService } from "./config/config.service"; +import { EmailModule } from "./email/email.module"; import { FileController } from "./file/file.controller"; import { FileModule } from "./file/file.module"; import { PrismaModule } from "./prisma/prisma.module"; @@ -13,7 +16,6 @@ import { PrismaService } from "./prisma/prisma.service"; import { ShareController } from "./share/share.controller"; import { ShareModule } from "./share/share.module"; import { UserController } from "./user/user.controller"; -import { EmailModule } from "./email/email.module"; @Module({ imports: [ @@ -22,7 +24,7 @@ import { EmailModule } from "./email/email.module"; FileModule, EmailModule, PrismaModule, - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule, ThrottlerModule.forRoot({ ttl: 60, limit: 100, @@ -30,8 +32,16 @@ import { EmailModule } from "./email/email.module"; ScheduleModule.forRoot(), ], providers: [ + ConfigService, PrismaService, JobsService, + { + provide: "CONFIG_VARIABLES", + useFactory: async (prisma: PrismaService) => { + return await prisma.config.findMany(); + }, + inject: [PrismaService], + }, { provide: APP_GUARD, useClass: ThrottlerGuard, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 5c722ec6..f1d544a1 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -5,8 +5,8 @@ import { HttpCode, Post, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { Throttle } from "@nestjs/throttler"; +import { ConfigService } from "src/config/config.service"; import { AuthService } from "./auth.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; @@ -21,8 +21,8 @@ export class AuthController { @Throttle(10, 5 * 60) @Post("signUp") - signUp(@Body() dto: AuthRegisterDTO) { - if (this.config.get("ALLOW_REGISTRATION") == "false") + async signUp(@Body() dto: AuthRegisterDTO) { + if (!this.config.get("allowRegistration")) throw new ForbiddenException("Registration is not allowed"); return this.authService.signUp(dto); } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d1925895..67d4e65f 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -3,12 +3,12 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import * as argon from "argon2"; import * as moment from "moment"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; @@ -68,7 +68,7 @@ export class AuthService { }, { expiresIn: "15min", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } diff --git a/backend/src/auth/guard/jwt.guard.ts b/backend/src/auth/guard/jwt.guard.ts index 20b684d6..07854dde 100644 --- a/backend/src/auth/guard/jwt.guard.ts +++ b/backend/src/auth/guard/jwt.guard.ts @@ -1,15 +1,17 @@ -import { ExecutionContext } from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; +import { ConfigService } from "src/config/config.service"; +@Injectable() export class JwtGuard extends AuthGuard("jwt") { - constructor() { + constructor(private config: ConfigService) { super(); } async canActivate(context: ExecutionContext): Promise { try { return (await super.canActivate(context)) as boolean; } catch { - return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true"; + return this.config.get("allowUnauthenticatedShares"); } } } diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index 44014209..2425d9fa 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -1,24 +1,27 @@ import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { User } from "@prisma/client"; import { ExtractJwt, Strategy } from "passport-jwt"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(config: ConfigService, private prisma: PrismaService) { + console.log(config.get("jwtSecret")); + config.get("jwtSecret"); super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: config.get("JWT_SECRET"), + secretOrKey: config.get("jwtSecret"), }); } async validate(payload: { sub: string }) { + console.log("vali"); const user: User = await this.prisma.user.findUnique({ where: { id: payload.sub }, }); - + console.log({ user }); return user; } } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts new file mode 100644 index 00000000..27291e84 --- /dev/null +++ b/backend/src/config/config.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from "@nestjs/common"; +import { ConfigService } from "./config.service"; +import { ConfigDTO } from "./dto/config.dto"; + +@Controller("configs") +export class ConfigController { + constructor(private configService: ConfigService) {} + + @Get() + async list() { + return new ConfigDTO().fromList(await this.configService.list()) + } + + @Get("admin") + async listForAdmin() { + return await this.configService.listForAdmin(); + } +} diff --git a/backend/src/config/config.module.ts b/backend/src/config/config.module.ts new file mode 100644 index 00000000..c19f5993 --- /dev/null +++ b/backend/src/config/config.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ConfigController } from "./config.controller"; +import { ConfigService } from "./config.service"; + +@Global() +@Module({ + providers: [ + { + provide: "CONFIG_VARIABLES", + useFactory: async (prisma: PrismaService) => { + return await prisma.config.findMany(); + }, + inject: [PrismaService], + }, + ConfigService, + ], + controllers: [ConfigController], + exports: [ConfigService], +}) +export class ConfigModule {} diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts new file mode 100644 index 00000000..9e94f4e0 --- /dev/null +++ b/backend/src/config/config.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Config } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ConfigService { + constructor( + @Inject("CONFIG_VARIABLES") private configVariables: Config[], + private prisma: PrismaService + ) {} + + get(key: string): any { + const configVariable = this.configVariables.filter( + (variable) => variable.key == key + )[0]; + + if (!configVariable) throw new Error(`Config variable ${key} not found`); + + const value = configVariable.value ?? configVariable.default; + + if (configVariable.type == "number") return parseInt(value); + if (configVariable.type == "boolean") return value == "true"; + if (configVariable.type == "string") return value; + } + + async listForAdmin() { + return await this.prisma.config.findMany(); + } + + async list() { + const configVariables = await this.prisma.config.findMany({ + where: { secret: { equals: false } }, + }); + + return configVariables.map((configVariable) => { + if (!configVariable.value) configVariable.value = configVariable.default; + + return configVariable; + }); + } +} diff --git a/backend/src/config/dto/config.dto.ts b/backend/src/config/dto/config.dto.ts new file mode 100644 index 00000000..1c2a779f --- /dev/null +++ b/backend/src/config/dto/config.dto.ts @@ -0,0 +1,18 @@ +import { Expose, plainToClass } from "class-transformer"; + +export class ConfigDTO { + @Expose() + key: string; + + @Expose() + value: string; + + @Expose() + type: string; + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }) + ); + } +} diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 336f505a..110212f1 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -1,34 +1,35 @@ import { Injectable, InternalServerErrorException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { User } from "@prisma/client"; import * as nodemailer from "nodemailer"; +import { ConfigService } from "src/config/config.service"; @Injectable() export class EmailService { constructor(private config: ConfigService) {} - // create reusable transporter object using the default SMTP transport - transporter = nodemailer.createTransport({ - host: this.config.get("SMTP_HOST"), - port: parseInt(this.config.get("SMTP_PORT")), - secure: parseInt(this.config.get("SMTP_PORT")) == 465, - auth: { - user: this.config.get("SMTP_EMAIL"), - pass: this.config.get("SMTP_PASSWORD"), - }, - }); - async sendMail(recipientEmail: string, shareId: string, creator: User) { - if (this.config.get("EMAIL_RECIPIENTS_ENABLED") == "false") + // create reusable transporter object using the default SMTP transport + const transporter = nodemailer.createTransport({ + host: this.config.get("SMTP_HOST"), + port: parseInt(this.config.get("SMTP_PORT")), + secure: parseInt(this.config.get("SMTP_PORT")) == 465, + auth: { + user: this.config.get("SMTP_EMAIL"), + pass: this.config.get("SMTP_PASSWORD"), + }, + }); + + if (!this.config.get("emailRecepientsEnabled")) throw new InternalServerErrorException("Email service disabled"); const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; - const creatorIdentifier = creator ? - creator.firstName && creator.lastName + const creatorIdentifier = creator + ? creator.firstName && creator.lastName ? `${creator.firstName} ${creator.lastName}` - : creator.email : "A Pingvin Share user"; + : creator.email + : "A Pingvin Share user"; - await this.transporter.sendMail({ + await transporter.sendMail({ from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, to: recipientEmail, subject: "Files shared with you", diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index 0d9c9f82..f9607bf3 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -2,7 +2,6 @@ import { Controller, Get, Param, - ParseFilePipeBuilder, Post, Res, StreamableFile, @@ -19,6 +18,7 @@ import { ShareDTO } from "src/share/dto/share.dto"; import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard"; import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; import { FileService } from "./file.service"; +import { FileValidationPipe } from "./pipe/fileValidation.pipe"; @Controller("shares/:shareId/files") export class FileController { @@ -32,13 +32,7 @@ export class FileController { }) ) async create( - @UploadedFile( - new ParseFilePipeBuilder() - .addMaxSizeValidator({ - maxSize: parseInt(process.env.MAX_FILE_SIZE), - }) - .build() - ) + @UploadedFile(FileValidationPipe) file: Express.Multer.File, @Param("shareId") shareId: string ) { diff --git a/backend/src/file/file.module.ts b/backend/src/file/file.module.ts index f3be62be..84b89b44 100644 --- a/backend/src/file/file.module.ts +++ b/backend/src/file/file.module.ts @@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt"; import { ShareModule } from "src/share/share.module"; import { FileController } from "./file.controller"; import { FileService } from "./file.service"; +import { FileValidationPipe } from "./pipe/fileValidation.pipe"; @Module({ imports: [JwtModule.register({}), ShareModule], controllers: [FileController], - providers: [FileService], + providers: [FileService, FileValidationPipe], exports: [FileService], }) export class FileModule {} diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 2437687e..cf662c32 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -3,11 +3,11 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { randomUUID } from "crypto"; import * as fs from "fs"; import * as mime from "mime-types"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() @@ -78,14 +78,14 @@ export class FileService { return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); } - getFileDownloadUrl(shareId: string, fileId: string) { + async getFileDownloadUrl(shareId: string, fileId: string) { const downloadToken = this.generateFileDownloadToken(shareId, fileId); return `${this.config.get( "APP_URL" )}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`; } - generateFileDownloadToken(shareId: string, fileId: string) { + async generateFileDownloadToken(shareId: string, fileId: string) { if (fileId == "zip") fileId = undefined; return this.jwtService.sign( @@ -95,15 +95,15 @@ export class FileService { }, { expiresIn: "10min", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } - verifyFileDownloadToken(shareId: string, token: string) { + async verifyFileDownloadToken(shareId: string, token: string) { try { const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), }); return claims.shareId == shareId; } catch { diff --git a/backend/src/file/pipe/fileValidation.pipe.ts b/backend/src/file/pipe/fileValidation.pipe.ts new file mode 100644 index 00000000..964bd835 --- /dev/null +++ b/backend/src/file/pipe/fileValidation.pipe.ts @@ -0,0 +1,13 @@ +import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; +import { ConfigService } from "src/config/config.service"; + +@Injectable() +export class FileValidationPipe implements PipeTransform { + constructor(private config: ConfigService) {} + async transform(value: any, metadata: ArgumentMetadata) { + // "value" is an object containing the file's attributes and metadata + console.log(this.config.get("maxFileSize")); + const oneKb = 1000; + return value.size < oneKb; + } +} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index eb1f1d21..215fa4ea 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -4,7 +4,7 @@ import { PrismaClient } from "@prisma/client"; @Injectable() export class PrismaService extends PrismaClient { - constructor(config: ConfigService) { + constructor() { super({ datasources: { db: { diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 572e7ba8..777ea737 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -4,13 +4,13 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { Share, User } from "@prisma/client"; import * as archiver from "archiver"; import * as argon from "argon2"; import * as fs from "fs"; import * as moment from "moment"; +import { ConfigService } from "src/config/config.service"; import { EmailService } from "src/email/email.service"; import { FileService } from "src/file/file.service"; import { PrismaService } from "src/prisma/prisma.service"; @@ -235,7 +235,7 @@ export class ShareService { }, { expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } @@ -247,7 +247,7 @@ export class ShareService { try { const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), // Ignore expiration if expiration is 0 ignoreExpiration: moment(expiration).isSame(0), });