Merge remote-tracking branch 'stonith404/main' into main

This commit is contained in:
Elias Schneider
2022-10-16 00:08:37 +02:00
47 changed files with 6692 additions and 881 deletions

View File

@@ -27,6 +27,7 @@ export class AuthController {
}
@Post("signIn")
@HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) {
return this.authService.signIn(dto);
}

View File

@@ -1,4 +1,3 @@
import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends UserDTO {}

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsString } from "class-validator";
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()

View File

@@ -14,7 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
async validate(payload: any) {
async validate(payload: { sub: string }) {
const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub },
});

View File

@@ -11,9 +11,6 @@ export class FileDTO {
@Expose()
size: string;
@Expose()
url: boolean;
share: ShareDTO;
from(partial: Partial<FileDTO>) {

View File

@@ -11,6 +11,7 @@ import {
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import * as contentDisposition from "content-disposition";
import { Response } from "express";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
@@ -41,6 +42,10 @@ export class FileController {
file: Express.Multer.File,
@Param("shareId") shareId: string
) {
// Fixes file names with special characters
file.originalname = Buffer.from(file.originalname, "latin1").toString(
"utf8"
);
return new ShareDTO().from(await this.fileService.create(file, shareId));
}
@@ -98,7 +103,7 @@ export class FileController {
res.set({
"Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size,
"Content-Disposition": `attachment ; filename="${file.metaData.name}"`,
"Content-Disposition": contentDisposition(file.metaData.name),
});
return new StreamableFile(file.file);

View File

@@ -1,7 +1,6 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ShareModule } from "src/share/share.module";
import { ShareService } from "src/share/share.service";
import { FileController } from "./file.controller";
import { FileService } from "./file.service";

View File

@@ -100,12 +100,12 @@ export class FileService {
);
}
verifyFileDownloadToken(shareId: string, fileId: string, token: string) {
verifyFileDownloadToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId && claims.fileId == fileId;
return claims.shareId == shareId;
} catch {
return false;
}

View File

@@ -1,23 +1,17 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class FileDownloadGuard implements CanActivate {
constructor(
private reflector: Reflector,
private fileService: FileService,
private prisma: PrismaService
) {}
constructor(private fileService: FileService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const token = request.query.token as string;
const { shareId, fileId } = request.params;
const { shareId } = request.params;
return this.fileService.verifyFileDownloadToken(shareId, fileId, token);
return this.fileService.verifyFileDownloadToken(shareId, token);
}
}

View File

@@ -1,5 +1,5 @@
import { Type } from "class-transformer";
import { IsString, Matches, ValidateNested } from "class-validator";
import { IsString, Length, Matches, ValidateNested } from "class-validator";
import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO {
@@ -7,6 +7,7 @@ export class CreateShareDTO {
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
message: "ID only can contain letters, numbers, underscores and hyphens",
})
@Length(3, 50)
id: string;
@IsString()

View File

@@ -1,6 +1,3 @@
import { IsNotEmpty } from "class-validator";
export class SharePasswordDto {
@IsNotEmpty()
password: string;
}

View File

@@ -1,16 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { ExtractJwt } from "passport-jwt";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
@Injectable()
export class ShareOwnerGuard implements CanActivate {
constructor(
private prisma: PrismaService
) {}
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -26,7 +26,7 @@ export class ShareOwnerGuard implements CanActivate {
include: { security: true },
});
if (!share) throw new NotFoundException("Share not found");
return share.creatorId == (request.user as User).id;
}

View File

@@ -21,6 +21,7 @@ export class ShareSecurityGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
@@ -36,19 +37,15 @@ export class ShareSecurityGuard implements CanActivate {
if (!share || (moment().isAfter(share.expiration) && moment(share.expiration).unix() !== 0))
throw new NotFoundException("Share not found");
if (!share.security) return true;
if (share.security.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
if (
!this.shareService.verifyShareToken(shareId, request.get("X-Share-Token"))
)
if (share.security?.password && !shareToken)
throw new ForbiddenException(
"This share is password protected",
"share_password_required"
);
if (!this.shareService.verifyShareToken(shareId, shareToken))
throw new ForbiddenException(
"Share token required",
"share_token_required"
);

View File

@@ -0,0 +1,47 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
@Injectable()
export class ShareTokenSecurity implements CanActivate {
constructor(
private reflector: Reflector,
private shareService: ShareService,
private prisma: PrismaService
) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
)
? request.params.shareId
: request.params.id;
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
});
if (!share || moment().isAfter(share.expiration))
throw new NotFoundException("Share not found");
if (share.security?.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
return true;
}
}

View File

@@ -18,6 +18,7 @@ import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto";
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service";
@Controller("shares")
@@ -68,11 +69,10 @@ export class ShareController {
return this.shareService.isShareIdAvailable(id);
}
@Post(":id/password")
async exchangeSharePasswordWithToken(
@Param("id") id: string,
@Body() body: SharePasswordDto
) {
return this.shareService.exchangeSharePasswordWithToken(id, body.password);
@HttpCode(200)
@UseGuards(ShareTokenSecurity)
@Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
return this.shareService.getShareToken(id, body.password);
}
}

View File

@@ -1,8 +1,8 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
@@ -17,195 +17,213 @@ import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable()
export class ShareService {
constructor(
private prisma: PrismaService,
private fileService: FileService,
private config: ConfigService,
private jwtService: JwtService
) {
constructor(
private prisma: PrismaService,
private fileService: FileService,
private config: ConfigService,
private jwtService: JwtService
) {}
async create(share: CreateShareDTO, user: User) {
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use");
if (!share.security || Object.keys(share.security).length == 0)
share.security = undefined;
if (share.security?.password) {
share.security.password = await argon.hash(share.security.password);
}
async create(share: CreateShareDTO, user: User) {
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use");
// We have to add an exception for "never" (since moment won't like that)
let expirationDate;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
if (!share.security || Object.keys(share.security).length == 0)
share.security = undefined;
if (share.security?.password) {
share.security.password = await argon.hash(share.security.password);
}
// We have to add an exception for "never" (since moment won't like that)
let expirationDate;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
// Throw error if expiration date is now
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
throw new BadRequestException("Invalid expiration date");
} else {
expirationDate = moment(0).toDate();
}
return await this.prisma.share.create({
data: {
...share,
expiration: expirationDate,
creator: {connect: {id: user.id}},
security: {create: share.security},
},
});
// Throw error if expiration date is now
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
throw new BadRequestException("Invalid expiration date");
} else {
expirationDate = moment(0).toDate();
}
async createZip(shareId: string) {
const path = `./data/uploads/shares/${shareId}`;
return await this.prisma.share.create({
data: {
...share,
expiration: expirationDate,
creator: { connect: { id: user.id } },
security: { create: share.security },
},
});
}
const files = await this.prisma.file.findMany({where: {shareId}});
const archive = archiver("zip", {
zlib: {level: 9},
});
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
async createZip(shareId: string) {
const path = `./data/uploads/shares/${shareId}`;
for (const file of files) {
archive.append(fs.createReadStream(`${path}/${file.id}`), {
name: file.name,
});
}
const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", {
zlib: { level: 9 },
});
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
archive.pipe(writeStream);
await archive.finalize();
for (const file of files) {
archive.append(fs.createReadStream(`${path}/${file.id}`), {
name: file.name,
});
}
async complete(id: string) {
const moreThanOneFileInShare =
(await this.prisma.file.findMany({where: {shareId: id}})).length != 0;
archive.pipe(writeStream);
await archive.finalize();
}
if (!moreThanOneFileInShare)
throw new BadRequestException(
"You need at least on file in your share to complete it."
);
async complete(id: string) {
if (await this.isShareCompleted(id))
throw new BadRequestException("Share already completed");
this.createZip(id).then(() =>
this.prisma.share.update({where: {id}, data: {isZipReady: true}})
);
const moreThanOneFileInShare =
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
return await this.prisma.share.update({
where: {id},
data: {uploadLocked: true},
});
}
async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({
where: {
creator: {id: userId},
// We want to grab any shares that are not expired or have their expiration date set to "never" (unix 0)
OR: [{expiration: {gt: new Date()}}, {expiration: {equals: moment(0).toDate()}}]
},
});
}
async get(id: string) {
let share: any = await this.prisma.share.findUnique({
where: {id},
include: {
files: true,
creator: true,
},
});
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
share.files = share.files.map((file) => {
file["url"] = `http://localhost:8080/file/${file.id}`;
return file;
});
await this.increaseViewCount(share);
return share;
}
async getMetaData(id: string) {
const share = await this.prisma.share.findUnique({
where: {id},
});
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
return share;
}
async remove(shareId: string) {
const share = await this.prisma.share.findUnique({
where: {id: shareId},
});
if (!share) throw new NotFoundException("Share not found");
await this.fileService.deleteAllFiles(shareId);
await this.prisma.share.delete({where: {id: shareId}});
}
async isShareCompleted(id: string) {
return (await this.prisma.share.findUnique({where: {id}})).uploadLocked;
}
async isShareIdAvailable(id: string) {
const share = await this.prisma.share.findUnique({where: {id}});
return {isAvailable: !share};
}
async increaseViewCount(share: Share) {
await this.prisma.share.update({
where: {id: share.id},
data: {views: share.views + 1},
});
}
async exchangeSharePasswordWithToken(shareId: string, password: string) {
const sharePassword = (
await this.prisma.shareSecurity.findFirst({
where: {share: {id: shareId}},
})
).password;
if (!(await argon.verify(sharePassword, password)))
throw new ForbiddenException("Wrong password");
const token = this.generateShareToken(shareId);
return {token};
}
generateShareToken(shareId: string) {
return this.jwtService.sign(
{
shareId,
},
{
expiresIn: "1h",
secret: this.config.get("JWT_SECRET"),
}
);
}
verifyShareToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId;
} catch {
return false;
}
if (!moreThanOneFileInShare)
throw new BadRequestException(
"You need at least on file in your share to complete it."
);
this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
);
return await this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
});
}
async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({
where: {
creator: { id: userId },
uploadLocked: true,
// We want to grab any shares that are not expired or have their expiration date set to "never" (unix 0)
OR: [
{ expiration: { gt: new Date() } },
{ expiration: { equals: moment(0).toDate() } },
],
},
orderBy: {
expiration: "desc",
},
});
}
async get(id: string) {
const share: any = await this.prisma.share.findUnique({
where: { id },
include: {
files: true,
creator: true,
},
});
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
return share;
}
async getMetaData(id: string) {
const share = await this.prisma.share.findUnique({
where: { id },
});
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
return share;
}
async remove(shareId: string) {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
});
if (!share) throw new NotFoundException("Share not found");
await this.fileService.deleteAllFiles(shareId);
await this.prisma.share.delete({ where: { id: shareId } });
}
async isShareCompleted(id: string) {
return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked;
}
async isShareIdAvailable(id: string) {
const share = await this.prisma.share.findUnique({ where: { id } });
return { isAvailable: !share };
}
async increaseViewCount(share: Share) {
await this.prisma.share.update({
where: { id: share.id },
data: { views: share.views + 1 },
});
}
async getShareToken(shareId: string, password: string) {
const share = await this.prisma.share.findFirst({
where: { id: shareId },
include: {
security: true,
},
});
if (
share?.security?.password &&
!(await argon.verify(share.security.password, password))
)
throw new ForbiddenException("Wrong password");
const token = await this.generateShareToken(shareId);
await this.increaseViewCount(share);
return { token };
}
async generateShareToken(shareId: string) {
const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId },
});
return this.jwtService.sign(
{
shareId,
},
{
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("JWT_SECRET"),
}
);
}
async verifyShareToken(shareId: string, token: string) {
const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId },
});
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
// Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0),
});
return claims.shareId == shareId;
} catch {
return false;
}
}
}