1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-07-02 07:20:38 +02:00

feat: add new config strategy to backend

This commit is contained in:
Elias Schneider 2022-11-28 15:04:32 +01:00
parent 13f98cc32c
commit 1b5e53ff7e
19 changed files with 304 additions and 50 deletions

View File

@ -9,6 +9,9 @@
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --write 'src/**/*.ts'",
"test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json" "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": { "dependencies": {
"@nestjs/common": "^9.1.2", "@nestjs/common": "^9.1.2",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",

View File

@ -77,3 +77,14 @@ model ShareSecurity {
shareId String? @unique shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade) 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)
}

View File

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

View File

@ -1,11 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./jobs/jobs.service"; import { JobsService } from "./jobs/jobs.service";
import { APP_GUARD } from "@nestjs/core"; import { APP_GUARD } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; 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 { FileController } from "./file/file.controller";
import { FileModule } from "./file/file.module"; import { FileModule } from "./file/file.module";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
@ -13,7 +16,6 @@ import { PrismaService } from "./prisma/prisma.service";
import { ShareController } from "./share/share.controller"; import { ShareController } from "./share/share.controller";
import { ShareModule } from "./share/share.module"; import { ShareModule } from "./share/share.module";
import { UserController } from "./user/user.controller"; import { UserController } from "./user/user.controller";
import { EmailModule } from "./email/email.module";
@Module({ @Module({
imports: [ imports: [
@ -22,7 +24,7 @@ import { EmailModule } from "./email/email.module";
FileModule, FileModule,
EmailModule, EmailModule,
PrismaModule, PrismaModule,
ConfigModule.forRoot({ isGlobal: true }), ConfigModule,
ThrottlerModule.forRoot({ ThrottlerModule.forRoot({
ttl: 60, ttl: 60,
limit: 100, limit: 100,
@ -30,8 +32,16 @@ import { EmailModule } from "./email/email.module";
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
], ],
providers: [ providers: [
ConfigService,
PrismaService, PrismaService,
JobsService, JobsService,
{
provide: "CONFIG_VARIABLES",
useFactory: async (prisma: PrismaService) => {
return await prisma.config.findMany();
},
inject: [PrismaService],
},
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ThrottlerGuard, useClass: ThrottlerGuard,

View File

@ -5,8 +5,8 @@ import {
HttpCode, HttpCode,
Post, Post,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
@ -21,8 +21,8 @@ export class AuthController {
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
signUp(@Body() dto: AuthRegisterDTO) { async signUp(@Body() dto: AuthRegisterDTO) {
if (this.config.get("ALLOW_REGISTRATION") == "false") if (!this.config.get("allowRegistration"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
return this.authService.signUp(dto); return this.authService.signUp(dto);
} }

View File

@ -3,12 +3,12 @@ import {
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2"; import * as argon from "argon2";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
@ -68,7 +68,7 @@ export class AuthService {
}, },
{ {
expiresIn: "15min", expiresIn: "15min",
secret: this.config.get("JWT_SECRET"), secret: this.config.get("jwtSecret"),
} }
); );
} }

View File

@ -1,15 +1,17 @@
import { ExecutionContext } from "@nestjs/common"; import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport"; import { AuthGuard } from "@nestjs/passport";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class JwtGuard extends AuthGuard("jwt") { export class JwtGuard extends AuthGuard("jwt") {
constructor() { constructor(private config: ConfigService) {
super(); super();
} }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
try { try {
return (await super.canActivate(context)) as boolean; return (await super.canActivate(context)) as boolean;
} catch { } catch {
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true"; return this.config.get("allowUnauthenticatedShares");
} }
} }
} }

View File

@ -1,24 +1,27 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { ExtractJwt, Strategy } from "passport-jwt"; import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
console.log(config.get("jwtSecret"));
config.get("jwtSecret");
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get("JWT_SECRET"), secretOrKey: config.get("jwtSecret"),
}); });
} }
async validate(payload: { sub: string }) { async validate(payload: { sub: string }) {
console.log("vali");
const user: User = await this.prisma.user.findUnique({ const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
}); });
console.log({ user });
return user; return user;
} }
} }

View File

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

View File

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

View File

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

View File

@ -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<ConfigDTO>[]) {
return partial.map((part) =>
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@ -1,34 +1,35 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as nodemailer from "nodemailer"; import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} 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) { 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"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
const creatorIdentifier = creator ? const creatorIdentifier = creator
creator.firstName && creator.lastName ? creator.firstName && creator.lastName
? `${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")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: "Files shared with you", subject: "Files shared with you",

View File

@ -2,7 +2,6 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
ParseFilePipeBuilder,
Post, Post,
Res, Res,
StreamableFile, StreamableFile,
@ -19,6 +18,7 @@ import { ShareDTO } from "src/share/dto/share.dto";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard"; import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
@Controller("shares/:shareId/files") @Controller("shares/:shareId/files")
export class FileController { export class FileController {
@ -32,13 +32,7 @@ export class FileController {
}) })
) )
async create( async create(
@UploadedFile( @UploadedFile(FileValidationPipe)
new ParseFilePipeBuilder()
.addMaxSizeValidator({
maxSize: parseInt(process.env.MAX_FILE_SIZE),
})
.build()
)
file: Express.Multer.File, file: Express.Multer.File,
@Param("shareId") shareId: string @Param("shareId") shareId: string
) { ) {

View File

@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt";
import { ShareModule } from "src/share/share.module"; import { ShareModule } from "src/share/share.module";
import { FileController } from "./file.controller"; import { FileController } from "./file.controller";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
@Module({ @Module({
imports: [JwtModule.register({}), ShareModule], imports: [JwtModule.register({}), ShareModule],
controllers: [FileController], controllers: [FileController],
providers: [FileService], providers: [FileService, FileValidationPipe],
exports: [FileService], exports: [FileService],
}) })
export class FileModule {} export class FileModule {}

View File

@ -3,11 +3,11 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import * as fs from "fs"; import * as fs from "fs";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@Injectable() @Injectable()
@ -78,14 +78,14 @@ export class FileService {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); 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); const downloadToken = this.generateFileDownloadToken(shareId, fileId);
return `${this.config.get( return `${this.config.get(
"APP_URL" "APP_URL"
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`; )}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
} }
generateFileDownloadToken(shareId: string, fileId: string) { async generateFileDownloadToken(shareId: string, fileId: string) {
if (fileId == "zip") fileId = undefined; if (fileId == "zip") fileId = undefined;
return this.jwtService.sign( return this.jwtService.sign(
@ -95,15 +95,15 @@ export class FileService {
}, },
{ {
expiresIn: "10min", 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 { try {
const claims = this.jwtService.verify(token, { const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"), secret: this.config.get("jwtSecret"),
}); });
return claims.shareId == shareId; return claims.shareId == shareId;
} catch { } catch {

View File

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

View File

@ -4,7 +4,7 @@ import { PrismaClient } from "@prisma/client";
@Injectable() @Injectable()
export class PrismaService extends PrismaClient { export class PrismaService extends PrismaClient {
constructor(config: ConfigService) { constructor() {
super({ super({
datasources: { datasources: {
db: { db: {

View File

@ -4,13 +4,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Share, User } from "@prisma/client"; import { Share, User } from "@prisma/client";
import * as archiver from "archiver"; import * as archiver from "archiver";
import * as argon from "argon2"; import * as argon from "argon2";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@ -235,7 +235,7 @@ export class ShareService {
}, },
{ {
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", 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 { try {
const claims = this.jwtService.verify(token, { const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"), secret: this.config.get("jwtSecret"),
// Ignore expiration if expiration is 0 // Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0), ignoreExpiration: moment(expiration).isSame(0),
}); });