feat: reverse shares (#86)
* add first concept * add reverse share funcionality to frontend * allow creator to limit share expiration * moved reverse share in seperate module * add table to manage reverse shares * delete complete share if reverse share was deleted * optimize function names * add db migration * enable reverse share email notifications * fix config variable descriptions * fix migration for new installations
This commit is contained in:
12
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
12
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsString } from "class-validator";
|
||||
|
||||
export class CreateReverseShareDTO {
|
||||
@IsBoolean()
|
||||
sendEmailNotification: boolean;
|
||||
|
||||
@IsString()
|
||||
maxShareSize: string;
|
||||
|
||||
@IsString()
|
||||
shareExpiration: string;
|
||||
}
|
||||
18
backend/src/reverseShare/dto/reverseShare.dto.ts
Normal file
18
backend/src/reverseShare/dto/reverseShare.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
|
||||
export class ReverseShareDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
maxShareSize: string;
|
||||
|
||||
@Expose()
|
||||
shareExpiration: Date;
|
||||
|
||||
from(partial: Partial<ReverseShareDTO>) {
|
||||
return plainToClass(ReverseShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
23
backend/src/reverseShare/dto/reverseShareTokenWithShare.ts
Normal file
23
backend/src/reverseShare/dto/reverseShareTokenWithShare.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { OmitType } from "@nestjs/mapped-types";
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
||||
import { ReverseShareDTO } from "./reverseShare.dto";
|
||||
|
||||
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
|
||||
"shareExpiration",
|
||||
] as const) {
|
||||
@Expose()
|
||||
shareExpiration: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients"] as const))
|
||||
share: Omit<MyShareDTO, "recipients" | "files" | "from" | "fromList">;
|
||||
|
||||
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ReverseShareTokenWithShare, part, {
|
||||
excludeExtraneousValues: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
22
backend/src/reverseShare/guards/reverseShareOwner.guard.ts
Normal file
22
backend/src/reverseShare/guards/reverseShareOwner.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ReverseShareOwnerGuard implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const { reverseShareId } = request.params;
|
||||
|
||||
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||
where: { id: reverseShareId },
|
||||
});
|
||||
|
||||
if (!reverseShare) return false;
|
||||
|
||||
return reverseShare.creatorId == (request.user as User).id;
|
||||
}
|
||||
}
|
||||
64
backend/src/reverseShare/reverseShare.controller.ts
Normal file
64
backend/src/reverseShare/reverseShare.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
||||
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare";
|
||||
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
||||
import { ReverseShareService } from "./reverseShare.service";
|
||||
|
||||
@Controller("reverseShares")
|
||||
export class ReverseShareController {
|
||||
constructor(
|
||||
private reverseShareService: ReverseShareService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
||||
const token = await this.reverseShareService.create(body, user.id);
|
||||
|
||||
const link = `${this.config.get("APP_URL")}/upload/${token}`;
|
||||
|
||||
return { token, link };
|
||||
}
|
||||
|
||||
@Throttle(20, 60)
|
||||
@Get(":reverseShareToken")
|
||||
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||
|
||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||
|
||||
return new ReverseShareDTO().from(
|
||||
await this.reverseShareService.getByToken(reverseShareToken)
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
async getAllByUser(@GetUser() user: User) {
|
||||
return new ReverseShareTokenWithShare().fromList(
|
||||
await this.reverseShareService.getAllByUser(user.id)
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":reverseShareId")
|
||||
@UseGuards(JwtGuard, ReverseShareOwnerGuard)
|
||||
async remove(@Param("reverseShareId") id: string) {
|
||||
await this.reverseShareService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/reverseShare/reverseShare.module.ts
Normal file
12
backend/src/reverseShare/reverseShare.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ReverseShareController } from "./reverseShare.controller";
|
||||
import { ReverseShareService } from "./reverseShare.service";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => FileModule)],
|
||||
controllers: [ReverseShareController],
|
||||
providers: [ReverseShareService],
|
||||
exports: [ReverseShareService],
|
||||
})
|
||||
export class ReverseShareModule {}
|
||||
94
backend/src/reverseShare/reverseShare.service.ts
Normal file
94
backend/src/reverseShare/reverseShare.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ReverseShareService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService
|
||||
) {}
|
||||
|
||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||
// Parse date string to date
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
data.shareExpiration.split("-")[0],
|
||||
data.shareExpiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
|
||||
|
||||
if (globalMaxShareSize < data.maxShareSize)
|
||||
throw new BadRequestException(
|
||||
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
|
||||
);
|
||||
|
||||
const reverseShare = await this.prisma.reverseShare.create({
|
||||
data: {
|
||||
shareExpiration: expirationDate,
|
||||
maxShareSize: data.maxShareSize,
|
||||
sendEmailNotification: data.sendEmailNotification,
|
||||
creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return reverseShare.token;
|
||||
}
|
||||
|
||||
async getByToken(reverseShareToken: string) {
|
||||
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||
where: { token: reverseShareToken },
|
||||
});
|
||||
|
||||
return reverseShare;
|
||||
}
|
||||
|
||||
async getAllByUser(userId: string) {
|
||||
const reverseShares = await this.prisma.reverseShare.findMany({
|
||||
where: {
|
||||
creatorId: userId,
|
||||
shareExpiration: { gt: new Date() },
|
||||
},
|
||||
orderBy: {
|
||||
shareExpiration: "desc",
|
||||
},
|
||||
include: { share: { include: { creator: true } } },
|
||||
});
|
||||
|
||||
return reverseShares;
|
||||
}
|
||||
|
||||
async isValid(reverseShareToken: string) {
|
||||
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||
where: { token: reverseShareToken },
|
||||
});
|
||||
|
||||
if (!reverseShare) return false;
|
||||
|
||||
const isExpired = new Date() > reverseShare.shareExpiration;
|
||||
const isUsed = reverseShare.used;
|
||||
|
||||
return !(isExpired || isUsed);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const share = await this.prisma.share.findFirst({
|
||||
where: { reverseShare: { id } },
|
||||
});
|
||||
|
||||
if (share) {
|
||||
await this.prisma.share.delete({ where: { id: share.id } });
|
||||
await this.fileService.deleteAllFiles(share.id);
|
||||
} else {
|
||||
await this.prisma.reverseShare.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user