feat: add setup wizard

This commit is contained in:
Elias Schneider
2022-12-01 23:07:49 +01:00
parent 493705e4ef
commit b579b8f330
32 changed files with 689 additions and 179 deletions

View File

@@ -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,

View File

@@ -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 }],
},
});

View File

@@ -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;
}

View File

@@ -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) {}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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" },
});
}
}

View File

@@ -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<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true });
return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true,
});
}
fromList(partial: Partial<AdminConfigDTO>[]) {

View File

@@ -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;

View File

@@ -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 🐧`,
});
}
}

View File

@@ -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
) {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
}