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

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

View File

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

View File

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

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