diff --git a/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql b/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql new file mode 100644 index 00000000..32780097 --- /dev/null +++ b/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost. + - Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "Config" ( + "updatedAt" DATETIME NOT NULL, + "key" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "isAdmin" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt") SELECT "createdAt", "email", "id", "password", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d997f582..c2fb2e16 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,11 +12,10 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - email String @unique - password String - isAdministrator Boolean @default(false) - firstName String? - lastName String? + username String @unique + email String @unique + password String + isAdmin Boolean @default(false) shares Share[] refreshTokens RefreshToken[] @@ -81,10 +80,10 @@ model ShareSecurity { model Config { updatedAt DateTime @updatedAt - key String @id - type String - value String? - default String - secret Boolean @default(true) - locked Boolean @default(false) + key String @id + type String + value String + description 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 index 269d7fa0..a43b14dd 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -1,79 +1,8 @@ import { PrismaClient } from "@prisma/client"; +import configVariables from "../../src/configVariables"; 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({ @@ -85,14 +14,6 @@ async function main() { 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 }, - }); - } } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8de599b6..dcfefeb7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,11 +1,13 @@ -import { Module } from "@nestjs/common"; +import { HttpException, HttpStatus, Module } from "@nestjs/common"; import { ScheduleModule } from "@nestjs/schedule"; import { AuthModule } from "./auth/auth.module"; import { JobsService } from "./jobs/jobs.service"; import { APP_GUARD } from "@nestjs/core"; +import { MulterModule } from "@nestjs/platform-express"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { Request } from "express"; import { ConfigModule } from "./config/config.module"; import { ConfigService } from "./config/config.service"; import { EmailModule } from "./email/email.module"; @@ -25,6 +27,24 @@ import { UserController } from "./user/user.controller"; EmailModule, PrismaModule, ConfigModule, + MulterModule.registerAsync({ + useFactory: (config: ConfigService) => ({ + fileFilter: (req: Request, file, cb) => { + const maxFileSize = config.get("maxFileSize"); + const requestFileSize = parseInt(req.headers["content-length"]); + const isValidFileSize = requestFileSize <= maxFileSize; + cb( + !isValidFileSize && + new HttpException( + `File must be smaller than ${maxFileSize} bytes`, + HttpStatus.PAYLOAD_TOO_LARGE + ), + isValidFileSize + ); + }, + }), + inject: [ConfigService], + }), ThrottlerModule.forRoot({ ttl: 60, limit: 100, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 67d4e65f..f9da9aed 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -27,7 +27,9 @@ export class AuthService { const user = await this.prisma.user.create({ data: { email: dto.email, + username: dto.username, password: hash, + isAdmin: !this.config.get("setupFinished"), }, }); @@ -38,16 +40,22 @@ export class AuthService { } catch (e) { if (e instanceof PrismaClientKnownRequestError) { if (e.code == "P2002") { - throw new BadRequestException("Credentials taken"); + const duplicatedField: string = e.meta.target[0]; + throw new BadRequestException( + `A user with this ${duplicatedField} already exists` + ); } } } } async signIn(dto: AuthSignInDTO) { - const user = await this.prisma.user.findUnique({ + if (!dto.email && !dto.username) + throw new BadRequestException("Email or username is required"); + + const user = await this.prisma.user.findFirst({ where: { - email: dto.email, + OR: [{ email: dto.email }, { username: dto.username }], }, }); diff --git a/backend/src/auth/dto/authRegister.dto.ts b/backend/src/auth/dto/authRegister.dto.ts index 6335ac77..14a80e7f 100644 --- a/backend/src/auth/dto/authRegister.dto.ts +++ b/backend/src/auth/dto/authRegister.dto.ts @@ -1,3 +1,17 @@ +import { PickType } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsEmail, Length, Matches } from "class-validator"; import { UserDTO } from "src/user/dto/user.dto"; -export class AuthRegisterDTO extends UserDTO {} +export class AuthRegisterDTO extends PickType(UserDTO, ["password"] as const) { + @Expose() + @Matches("^[a-zA-Z0-9_.]*$", undefined, { + message: "Username can only contain letters, numbers, dots and underscores", + }) + @Length(3, 32) + username: string; + + @Expose() + @IsEmail() + email: string; +} diff --git a/backend/src/auth/dto/authSignIn.dto.ts b/backend/src/auth/dto/authSignIn.dto.ts index 61aec2bb..a6cf7cf3 100644 --- a/backend/src/auth/dto/authSignIn.dto.ts +++ b/backend/src/auth/dto/authSignIn.dto.ts @@ -2,6 +2,7 @@ import { PickType } from "@nestjs/swagger"; import { UserDTO } from "src/user/dto/user.dto"; export class AuthSignInDTO extends PickType(UserDTO, [ + "username", "email", "password", ] as const) {} diff --git a/backend/src/auth/guard/isAdmin.guard.ts b/backend/src/auth/guard/isAdmin.guard.ts index 16bd84b1..2e69ced6 100644 --- a/backend/src/auth/guard/isAdmin.guard.ts +++ b/backend/src/auth/guard/isAdmin.guard.ts @@ -3,9 +3,11 @@ import { User } from "@prisma/client"; @Injectable() export class AdministratorGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + canActivate(context: ExecutionContext) { const { user }: { user: User } = context.switchToHttp().getRequest(); + if (!user) return false; - return user.isAdministrator; + + return user.isAdmin; } } diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index 2425d9fa..d2172ca8 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -8,7 +8,6 @@ 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(), @@ -17,11 +16,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } 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 index 9e49900e..9ed77fb5 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -1,5 +1,14 @@ -import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; +import { JwtGuard } from "src/auth/guard/jwt.guard"; import { ConfigService } from "./config.service"; import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { ConfigDTO } from "./dto/config.dto"; @@ -15,7 +24,7 @@ export class ConfigController { } @Get("admin") - @UseGuards(AdministratorGuard) + @UseGuards(JwtGuard, AdministratorGuard) async listForAdmin() { return new AdminConfigDTO().fromList( await this.configService.listForAdmin() @@ -23,10 +32,16 @@ export class ConfigController { } @Patch("admin/:key") - @UseGuards(AdministratorGuard) + @UseGuards(JwtGuard, AdministratorGuard) async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { return new AdminConfigDTO().from( await this.configService.update(key, data.value) ); } + + @Post("admin/finishSetup") + @UseGuards(JwtGuard, AdministratorGuard) + async finishSetup() { + return await this.configService.finishSetup(); + } } diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index a844bd06..04b19746 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -21,27 +21,21 @@ export class ConfigService { 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; + if (configVariable.type == "number") return parseInt(configVariable.value); + if (configVariable.type == "boolean") return configVariable.value == "true"; + if (configVariable.type == "string") return configVariable.value; } async listForAdmin() { - return await this.prisma.config.findMany(); + return await this.prisma.config.findMany({ + where: { locked: { equals: false } }, + }); } async list() { - const configVariables = await this.prisma.config.findMany({ + return await this.prisma.config.findMany({ where: { secret: { equals: false } }, }); - - return configVariables.map((configVariable) => { - if (!configVariable.value) configVariable.value = configVariable.default; - - return configVariable; - }); } async update(key: string, value: string | number | boolean) { @@ -57,9 +51,20 @@ export class ConfigService { `Config variable must be of type ${configVariable.type}` ); - return await this.prisma.config.update({ + const updatedVariable = await this.prisma.config.update({ where: { key }, data: { value: value.toString() }, }); + + this.configVariables = await this.prisma.config.findMany(); + + return updatedVariable; + } + + async finishSetup() { + return await this.prisma.config.update({ + where: { key: "setupFinished" }, + data: { value: "true" }, + }); } } diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts index 2cd135d8..ab32c194 100644 --- a/backend/src/config/dto/adminConfig.dto.ts +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -2,17 +2,19 @@ import { Expose, plainToClass } from "class-transformer"; import { ConfigDTO } from "./config.dto"; export class AdminConfigDTO extends ConfigDTO { - @Expose() - default: string; - @Expose() secret: boolean; @Expose() updatedAt: Date; + @Expose() + description: string; + from(partial: Partial) { - return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true }); + return plainToClass(AdminConfigDTO, partial, { + excludeExtraneousValues: true, + }); } fromList(partial: Partial[]) { diff --git a/backend/src/configVariables.ts b/backend/src/configVariables.ts new file mode 100644 index 00000000..b11be41c --- /dev/null +++ b/backend/src/configVariables.ts @@ -0,0 +1,88 @@ +import * as crypto from "crypto"; + +const configVariables = [ + { + key: "setupFinished", + description: "Whether the setup has been finished", + type: "boolean", + value: "false", + secret: false, + locked: true, + }, + { + key: "appUrl", + description: "On which URL Pingvin Share is available", + type: "string", + value: "http://localhost:3000", + secret: false, + }, + { + key: "showHomePage", + description: "Whether to show the home page", + type: "boolean", + value: "true", + secret: false, + }, + { + key: "allowRegistration", + description: "Whether registration is allowed", + type: "boolean", + value: "true", + secret: false, + }, + { + key: "allowUnauthenticatedShares", + description: "Whether unauthorized users can create shares", + type: "boolean", + value: "false", + secret: false, + }, + { + key: "maxFileSize", + description: "Maximum file size in bytes", + type: "number", + value: "1000000000", + secret: false, + }, + { + key: "jwtSecret", + description: "Long random string used to sign JWT tokens", + type: "string", + value: crypto.randomBytes(256).toString("base64"), + locked: true, + }, + { + key: "emailRecipientsEnabled", + description: + "Whether to send emails to recipients. Only set this to true if you entered the host, port, email and password of your SMTP server.", + type: "boolean", + value: "false", + secret: false, + }, + { + key: "smtpHost", + description: "Host of the SMTP server", + type: "string", + value: "", + }, + { + key: "smtpPort", + description: "Port of the SMTP server", + type: "number", + value: "", + }, + { + key: "smtpEmail", + description: "Email address of the SMTP server", + type: "string", + value: "", + }, + { + key: "smtpPassword", + description: "Password of the SMTP server", + type: "string", + value: "", + }, +]; + +export default configVariables; diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 110212f1..4c56fc69 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -23,17 +23,13 @@ export class EmailService { throw new InternalServerErrorException("Email service disabled"); const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; - const creatorIdentifier = creator - ? creator.firstName && creator.lastName - ? `${creator.firstName} ${creator.lastName}` - : creator.email - : "A Pingvin Share user"; + await transporter.sendMail({ from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, to: recipientEmail, subject: "Files shared with you", - text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`, + text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`, }); } } diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index f9607bf3..3bb6d61e 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -18,7 +18,6 @@ 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,7 +31,7 @@ export class FileController { }) ) async create( - @UploadedFile(FileValidationPipe) + @UploadedFile() file: Express.Multer.File, @Param("shareId") shareId: string ) { diff --git a/backend/src/file/pipe/fileValidation.pipe.ts b/backend/src/file/pipe/fileValidation.pipe.ts index 964bd835..6a0ec86b 100644 --- a/backend/src/file/pipe/fileValidation.pipe.ts +++ b/backend/src/file/pipe/fileValidation.pipe.ts @@ -1,13 +1,17 @@ -import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; +import { + ArgumentMetadata, + BadRequestException, + 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; + if (value.size > this.config.get("maxFileSize")) + throw new BadRequestException("File is "); + return value; } } diff --git a/backend/src/share/dto/createShare.dto.ts b/backend/src/share/dto/createShare.dto.ts index 644b1164..ecb817ae 100644 --- a/backend/src/share/dto/createShare.dto.ts +++ b/backend/src/share/dto/createShare.dto.ts @@ -11,7 +11,7 @@ import { ShareSecurityDTO } from "./shareSecurity.dto"; export class CreateShareDTO { @IsString() @Matches("^[a-zA-Z0-9_-]*$", undefined, { - message: "ID only can contain letters, numbers, underscores and hyphens", + message: "ID can only contain letters, numbers, underscores and hyphens", }) @Length(3, 50) id: string; diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index ae6411ef..479c99df 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -1,18 +1,17 @@ import { Expose, plainToClass } from "class-transformer"; -import { IsEmail, IsNotEmpty, IsString } from "class-validator"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; export class UserDTO { @Expose() id: string; @Expose() - firstName: string; + @IsOptional() + @IsString() + username: string; @Expose() - lastName: string; - - @Expose() - @IsNotEmpty() + @IsOptional() @IsEmail() email: string; @@ -20,6 +19,9 @@ export class UserDTO { @IsString() password: string; + @Expose() + isAdmin: boolean; + from(partial: Partial) { return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); } diff --git a/frontend/src/components/admin/AdminConfigTable.tsx b/frontend/src/components/admin/AdminConfigTable.tsx new file mode 100644 index 00000000..570f5aa7 --- /dev/null +++ b/frontend/src/components/admin/AdminConfigTable.tsx @@ -0,0 +1,95 @@ +import { ActionIcon, Code, Group, Skeleton, Table, Text } from "@mantine/core"; +import { useModals } from "@mantine/modals"; +import { useEffect, useState } from "react"; +import { TbEdit, TbLock } from "react-icons/tb"; +import configService from "../../services/config.service"; +import { AdminConfig as AdminConfigType } from "../../types/config.type"; +import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal"; + +const AdminConfigTable = () => { + const modals = useModals(); + + const [isLoading, setIsLoading] = useState(false); + + const [configVariables, setConfigVariables] = useState([]); + + const getConfigVariables = () => { + setIsLoading(true); + configService.listForAdmin().then((configVariables) => { + setConfigVariables(configVariables); + setIsLoading(false); + }); + }; + + useEffect(() => { + getConfigVariables(); + }, []); + + const skeletonRows = [...Array(9)].map((c, i) => ( + + + + + + + + + + + + + + + + )); + + return ( + + + + + + + + + + {isLoading + ? skeletonRows + : configVariables.map((element) => ( + + + + + + + ))} + +
KeyValue
+ {element.key} {element.secret && }{" "} +
+ + {" "} + {element.description} + +
{element.value} + + + showUpdateConfigVariableModal( + modals, + element, + getConfigVariables + ) + } + > + + + +
+ ); +}; + +export default AdminConfigTable; diff --git a/frontend/src/components/admin/showUpdateConfigVariableModal.tsx b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx new file mode 100644 index 00000000..f74e1744 --- /dev/null +++ b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx @@ -0,0 +1,96 @@ +import { + Button, + Code, + NumberInput, + Select, + Space, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useModals } from "@mantine/modals"; +import { ModalsContextProps } from "@mantine/modals/lib/context"; +import configService from "../../services/config.service"; +import { AdminConfig } from "../../types/config.type"; +import toast from "../../utils/toast.util"; + +const showUpdateConfigVariableModal = ( + modals: ModalsContextProps, + configVariable: AdminConfig, + getConfigVariables: () => void +) => { + return modals.openModal({ + title: Update configuration variable, + children: ( + + ), + }); +}; + +const Body = ({ + configVariable, + getConfigVariables, +}: { + configVariable: AdminConfig; + getConfigVariables: () => void; +}) => { + const modals = useModals(); + + const form = useForm({ + initialValues: { + stringValue: configVariable.value, + numberValue: parseInt(configVariable.value), + booleanValue: configVariable.value, + }, + }); + return ( + + + Set {configVariable.key} to + + {configVariable.type == "string" && ( + + )} + {configVariable.type == "number" && ( + + )} + {configVariable.type == "boolean" && ( +