feat: remove appwrite and add nextjs backend

This commit is contained in:
Elias Schneider
2022-10-09 22:30:32 +02:00
parent 7728351158
commit 4bab33ad8a
153 changed files with 13400 additions and 2811 deletions

View File

@@ -0,0 +1,18 @@
import { Type } from "class-transformer";
import { IsString, Matches, ValidateNested } from "class-validator";
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",
})
id: string;
@IsString()
expiration: string;
@ValidateNested()
@Type(() => ShareSecurityDTO)
security: ShareSecurityDTO;
}

View File

@@ -0,0 +1,20 @@
import { Expose, plainToClass } from "class-transformer";
import { ShareDTO } from "./share.dto";
export class MyShareDTO extends ShareDTO {
@Expose()
views: number;
@Expose()
createdAt: Date;
from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
}
fromList(partial: Partial<MyShareDTO>[]) {
return partial.map((part) =>
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@@ -0,0 +1,29 @@
import { Expose, plainToClass, Type } from "class-transformer";
import { AuthDTO } from "src/auth/dto/auth.dto";
import { FileDTO } from "src/file/dto/file.dto";
export class ShareDTO {
@Expose()
id: string;
@Expose()
expiration: Date;
@Expose()
@Type(() => FileDTO)
files: FileDTO[];
@Expose()
@Type(() => AuthDTO)
creator: AuthDTO;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
}
fromList(partial: Partial<ShareDTO>[]) {
return partial.map((part) =>
plainToClass(ShareDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@@ -0,0 +1,15 @@
import { Expose, plainToClass } from "class-transformer";
export class ShareMetaDataDTO {
@Expose()
id: string;
@Expose()
isZipReady: boolean;
from(partial: Partial<ShareMetaDataDTO>) {
return plainToClass(ShareMetaDataDTO, partial, {
excludeExtraneousValues: true,
});
}
}

View File

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

View File

@@ -0,0 +1,12 @@
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
export class ShareSecurityDTO {
@IsString()
@IsOptional()
@Length(3, 30)
password: string;
@IsNumber()
@IsOptional()
maxViews: number;
}

View File

@@ -0,0 +1,57 @@
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 ShareSecurityGuard 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) 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"))
)
throw new ForbiddenException(
"This share is password protected",
"share_token_required"
);
return true;
}
}

View File

@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto";
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareService } from "./share.service";
@Controller("shares")
export class ShareController {
constructor(private shareService: ShareService) {}
@Get()
@UseGuards(JwtGuard)
async getMyShares(@GetUser() user: User) {
return new MyShareDTO().fromList(
await this.shareService.getSharesByUser(user.id)
);
}
@Get(":id")
@UseGuards(ShareSecurityGuard)
async get(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.get(id));
}
@Get(":id/metaData")
@UseGuards(ShareSecurityGuard)
async getMetaData(@Param("id") id: string) {
return new ShareMetaDataDTO().from(await this.shareService.getMetaData(id));
}
@Post()
@UseGuards(JwtGuard)
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
return new ShareDTO().from(await this.shareService.create(body, user));
}
@Delete(":id")
@UseGuards(JwtGuard)
async remove(@Param("id") id: string, @GetUser() user: User) {
await this.shareService.remove(id, user.id);
}
@Post(":id/complete")
@HttpCode(202)
@UseGuards(JwtGuard)
async complete(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.complete(id));
}
@Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id);
}
@Post(":id/password")
async exchangeSharePasswordWithToken(
@Param("id") id: string,
@Body() body: SharePasswordDto
) {
return this.shareService.exchangeSharePasswordWithToken(id, body.password);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt";
import { AuthModule } from "src/auth/auth.module";
import { FileModule } from "src/file/file.module";
import { ShareController } from "./share.controller";
import { ShareService } from "./share.service";
@Module({
imports: [JwtModule.register({})],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View File

@@ -0,0 +1,200 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Share, User } from "@prisma/client";
import * as archiver from "archiver";
import * as argon from "argon2";
import * as fs from "fs";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable()
export class ShareService {
constructor(
private prisma: PrismaService,
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);
}
const 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");
return await this.prisma.share.create({
data: {
...share,
expiration: expirationDate,
creator: { connect: { id: user.id } },
security: { create: share.security },
},
});
}
async createZip(shareId: string) {
const path = `./uploads/shares/${shareId}`;
const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", {
zlib: { level: 9 },
});
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
for (const file of files) {
archive.append(fs.createReadStream(`${path}/${file.id}`), {
name: file.name,
});
}
archive.pipe(writeStream);
await archive.finalize();
}
async complete(id: string) {
const moreThanOneFileInShare =
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
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 }, expiration: { gt: new Date() } },
});
}
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, userId: string) {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
});
if (!share) throw new NotFoundException("Share not found");
if (share.creatorId != userId) throw new ForbiddenException();
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;
}
}
}