mirror of
https://github.com/swissmakers/swiss-datashare.git
synced 2026-04-11 10:27:01 +02:00
feat: add setup wizard
This commit is contained in:
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>[]) {
|
||||
|
||||
88
backend/src/configVariables.ts
Normal file
88
backend/src/configVariables.ts
Normal 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;
|
||||
@@ -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 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user