1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-06-30 06:30:11 +02:00

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:
Elias Schneider 2023-01-26 13:44:04 +01:00 committed by GitHub
parent 1ceb07b89e
commit 4a5fb549c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1456 additions and 280 deletions

View File

@ -62,7 +62,7 @@
"eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2",
"prettier": "^2.8.2",
"prisma": "^4.8.1",
"prisma": "^4.9.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.2",
"tsconfig-paths": "4.1.2",
@ -1010,9 +1010,9 @@
}
},
"node_modules/@prisma/engines": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz",
"integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz",
"integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==",
"devOptional": true,
"hasInstallScript": true
},
@ -5871,13 +5871,13 @@
}
},
"node_modules/prisma": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz",
"integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz",
"integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==",
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/engines": "4.8.1"
"@prisma/engines": "4.9.0"
},
"bin": {
"prisma": "build/index.js",
@ -8255,9 +8255,9 @@
}
},
"@prisma/engines": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz",
"integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz",
"integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==",
"devOptional": true
},
"@prisma/engines-version": {
@ -11995,12 +11995,12 @@
}
},
"prisma": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz",
"integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz",
"integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==",
"devOptional": true,
"requires": {
"@prisma/engines": "4.8.1"
"@prisma/engines": "4.9.0"
}
},
"process-nextick-args": {

View File

@ -67,7 +67,7 @@
"eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2",
"prettier": "^2.8.2",
"prisma": "^4.8.1",
"prisma": "^4.9.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.2",
"tsconfig-paths": "4.1.2",

View File

@ -0,0 +1,91 @@
/*
Warnings:
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
- Added the required column `id` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"creatorId" TEXT NOT NULL,
"shareId" TEXT,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ReverseShare_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"id" INTEGER,
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_shareId_key" ON "ReverseShare"("shareId");
-- Add ids to existing settings
UPDATE Config SET id = 1 WHERE key = "SETUP_FINISHED";
UPDATE Config SET id = 2 WHERE key = "APP_URL";
UPDATE Config SET id = 3 WHERE key = "SHOW_HOME_PAGE";
UPDATE Config SET id = 4 WHERE key = "ALLOW_REGISTRATION";
UPDATE Config SET id = 5 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE Config SET id = 6 WHERE key = "MAX_SHARE_SIZE";
UPDATE Config SET id = 7 WHERE key = "JWT_SECRET";
UPDATE Config SET id = 8 WHERE key = "TOTP_SECRET";
UPDATE Config SET id = 9, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE Config SET id = 10, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE";
UPDATE Config SET id = 11, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT";
UPDATE Config SET id = 15 WHERE key = "SMTP_HOST";
UPDATE Config SET id = 16 WHERE key = "SMTP_PORT";
UPDATE Config SET id = 17 WHERE key = "SMTP_EMAIL";
UPDATE Config SET id = 18 WHERE key = "SMTP_USERNAME";
UPDATE Config SET id = 19 WHERE key = "SMTP_PASSWORD";
INSERT INTO Config (`id`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (14, "SMTP_ENABLED", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "boolean", IFNULL((SELECT value FROM Config WHERE key="ENABLE_SHARE_EMAIL_RECIPIENTS"), "false"), "smtp", 0, strftime('%s', 'now'));
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Config" ("id", "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "id", "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
DELETE from Config WHERE key="MAX_FILE_SIZE";
CREATE UNIQUE INDEX "Config_key_key" ON "Config"("key");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -20,6 +20,7 @@ model User {
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
@ -59,13 +60,33 @@ model Share {
description String?
removedReason String?
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShare ReverseShare?
security ShareSecurity?
recipients ShareRecipient[]
files File[]
}
model ReverseShare {
id String @id @default(uuid())
createdAt DateTime @default(now())
token String @unique @default(uuid())
shareExpiration DateTime
maxShareSize String
sendEmailNotification Boolean
used Boolean @default(false)
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model ShareRecipient {
id String @id @default(uuid())
email String
@ -97,9 +118,10 @@ model ShareSecurity {
}
model Config {
id Int @id
updatedAt DateTime @updatedAt
key String @id
key String @unique
type String
value String
description String

View File

@ -3,6 +3,7 @@ import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [
{
id: 1,
key: "SETUP_FINISHED",
description: "Whether the setup has been finished",
type: "boolean",
@ -12,6 +13,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
locked: true,
},
{
id: 2,
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
@ -20,6 +22,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false,
},
{
id: 3,
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
@ -28,6 +31,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false,
},
{
id: 4,
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false,
},
{
id: 5,
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
@ -44,6 +49,8 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false,
},
{
id: 6,
key: "MAX_SHARE_SIZE",
description: "Maximum share size in bytes",
type: "number",
@ -52,6 +59,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false,
},
{
id: 7,
key: "JWT_SECRET",
description: "Long random string used to sign JWT tokens",
type: "string",
@ -60,6 +68,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
locked: true,
},
{
id: 8,
key: "TOTP_SECRET",
description: "A 16 byte random string used to generate TOTP secrets",
type: "string",
@ -68,65 +77,103 @@ const configVariables: Prisma.ConfigCreateInput[] = [
locked: true,
},
{
key: "ENABLE_EMAIL_RECIPIENTS",
id: 9,
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
description:
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
type: "boolean",
value: "false",
category: "email",
secret: false,
},
{
key: "EMAIL_MESSAGE",
id: 10,
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
description:
"Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
category: "email",
},
{
key: "EMAIL_SUBJECT",
description: "Subject of the email which gets sent to the recipients.",
id: 11,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{
id: 12,
key: "REVERSE_SHARE_EMAIL_MESSAGE",
description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
id: 13,
key: "REVERSE_SHARE_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string",
value: "Reverse share link used",
category: "email",
},
{
id: 14,
key: "SMTP_ENABLED",
description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "boolean",
value: "false",
category: "smtp",
secret: false,
},
{
id: 15,
key: "SMTP_HOST",
description: "Host of the SMTP server",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
id: 16,
key: "SMTP_PORT",
description: "Port of the SMTP server",
type: "number",
value: "0",
category: "email",
category: "smtp",
},
{
id: 17,
key: "SMTP_EMAIL",
description: "Email address which the emails get sent from",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
id: 18,
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
id: 19,
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
category: "email",
category: "smtp",
},
];

View File

@ -13,6 +13,7 @@ import { PrismaModule } from "./prisma/prisma.module";
import { ShareModule } from "./share/share.module";
import { UserModule } from "./user/user.module";
import { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
@Module({
imports: [
@ -30,6 +31,7 @@ import { ClamScanModule } from "./clamscan/clamscan.module";
}),
ScheduleModule.forRoot(),
ClamScanModule,
ReverseShareModule,
],
providers: [
{

View File

@ -29,6 +29,7 @@ export class ConfigService {
async listForAdmin() {
return await this.prisma.config.findMany({
orderBy: { id: "asc" },
where: { locked: { equals: false } },
});
}

View File

@ -8,6 +8,9 @@ export class EmailService {
constructor(private config: ConfigService) {}
getTransporter() {
if (!this.config.get("SMTP_ENABLED"))
throw new InternalServerErrorException("SMTP is disabled");
return nodemailer.createTransport({
host: this.config.get("SMTP_HOST"),
port: parseInt(this.config.get("SMTP_PORT")),
@ -19,8 +22,12 @@ export class EmailService {
});
}
async sendMail(recipientEmail: string, shareId: string, creator: User) {
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
async sendMailToShareRecepients(
recipientEmail: string,
shareId: string,
creator?: User
) {
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS"))
throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
@ -28,11 +35,25 @@ export class EmailService {
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: this.config.get("EMAIL_SUBJECT"),
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
text: this.config
.get("EMAIL_MESSAGE")
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl),
});
}
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
text: this.config
.get("REVERSE_SHARE_EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator.username)
.replaceAll("{shareUrl}", shareUrl),
});
}

View File

@ -14,6 +14,7 @@ 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";
import { CreateShareGuard } from "src/share/guard/createShare.guard";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service";
@ -24,7 +25,7 @@ export class FileController {
@Post()
@SkipThrottle()
@UseGuards(JwtGuard, ShareOwnerGuard)
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async create(
@Query() query: any,

View File

@ -1,11 +1,12 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareModule } from "src/share/share.module";
import { FileController } from "./file.controller";
import { FileService } from "./file.service";
@Module({
imports: [JwtModule.register({}), ShareModule],
imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
controllers: [FileController],
providers: [FileService],
exports: [FileService],

View File

@ -30,7 +30,7 @@ export class FileService {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { files: true },
include: { files: true, reverseShare: true },
});
if (share.uploadLocked)
@ -64,9 +64,12 @@ export class FileService {
0
);
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
if (
fileSizeSum + diskFileSize + buffer.byteLength >
this.config.get("MAX_SHARE_SIZE")
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
(share.reverseShare?.maxShareSize &&
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
) {
throw new HttpException(
"Max share size exceeded",

View File

@ -0,0 +1,12 @@
import { IsBoolean, IsString } from "class-validator";
export class CreateReverseShareDTO {
@IsBoolean()
sendEmailNotification: boolean;
@IsString()
maxShareSize: string;
@IsString()
shareExpiration: string;
}

View 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,
});
}
}

View 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,
})
);
}
}

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

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

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

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

View File

@ -0,0 +1,29 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
@Injectable()
export class CreateShareGuard extends JwtGuard {
constructor(
configService: ConfigService,
private reverseShareService: ReverseShareService
) {
super(configService);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (await super.canActivate(context)) return true;
const reverseShareTokenId = context.switchToHttp().getRequest()
.cookies.reverse_share_token;
if (!reverseShareTokenId) return false;
const isReverseShareTokenValid = await this.reverseShareService.isValid(
reverseShareTokenId
);
return isReverseShareTokenValid;
}
}

View File

@ -6,24 +6,31 @@ import {
HttpCode,
Param,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
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 { CreateShareGuard } from "./guard/createShare.guard";
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")
export class ShareController {
constructor(private shareService: ShareService) {}
constructor(
private shareService: ShareService,
private config: ConfigService
) {}
@Get()
@UseGuards(JwtGuard)
@ -46,9 +53,16 @@ export class ShareController {
}
@Post()
@UseGuards(JwtGuard)
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
return new ShareDTO().from(await this.shareService.create(body, user));
@UseGuards(CreateShareGuard)
async create(
@Body() body: CreateShareDTO,
@Req() request: Request,
@GetUser() user: User
) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.create(body, user, reverse_share_token)
);
}
@Delete(":id")
@ -59,11 +73,15 @@ export class ShareController {
@Post(":id/complete")
@HttpCode(202)
@UseGuards(JwtGuard, ShareOwnerGuard)
async complete(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.complete(id));
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token)
);
}
@Throttle(10, 60)
@Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id);

View File

@ -3,6 +3,7 @@ import { JwtModule } from "@nestjs/jwt";
import { ClamScanModule } from "src/clamscan/clamscan.module";
import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareController } from "./share.controller";
import { ShareService } from "./share.service";
@ -11,6 +12,7 @@ import { ShareService } from "./share.service";
JwtModule.register({}),
EmailModule,
ClamScanModule,
ReverseShareModule,
forwardRef(() => FileModule),
],
controllers: [ShareController],

View File

@ -15,6 +15,7 @@ import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable()
@ -25,10 +26,11 @@ export class ShareService {
private emailService: EmailService,
private config: ConfigService,
private jwtService: JwtService,
private reverseShareService: ReverseShareService,
private clamScanService: ClamScanService
) {}
async create(share: CreateShareDTO, user?: User) {
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use");
@ -39,30 +41,36 @@ export class ShareService {
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: Date;
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");
// If share is created by a reverse share token override the expiration date
if (reverseShareToken) {
const { shareExpiration } = await this.reverseShareService.getByToken(
reverseShareToken
);
expirationDate = shareExpiration;
} else {
expirationDate = moment(0).toDate();
// We have to add an exception for "never" (since moment won't like that)
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
} else {
expirationDate = moment(0).toDate();
}
}
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
recursive: true,
});
return await this.prisma.share.create({
const shareTuple = await this.prisma.share.create({
data: {
...share,
expiration: expirationDate,
@ -75,6 +83,18 @@ export class ShareService {
},
},
});
if (reverseShareToken) {
// Assign share to reverse share token
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: {
shareId: share.id,
},
});
}
return shareTuple;
}
async createZip(shareId: string) {
@ -96,10 +116,15 @@ export class ShareService {
await archive.finalize();
}
async complete(id: string) {
async complete(id: string, reverseShareToken?: string) {
const share = await this.prisma.share.findUnique({
where: { id },
include: { files: true, recipients: true, creator: true },
include: {
files: true,
recipients: true,
creator: true,
reverseShare: { include: { creator: true } },
},
});
if (await this.isShareCompleted(id))
@ -118,16 +143,34 @@ export class ShareService {
// Send email for each recepient
for (const recepient of share.recipients) {
await this.emailService.sendMail(
await this.emailService.sendMailToShareRecepients(
recepient.email,
share.id,
share.creator
);
}
if (
share.reverseShare &&
this.config.get("SMTP_ENABLED") &&
share.reverseShare.sendEmailNotification
) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id
);
}
// Check if any file is malicious with ClamAV
this.clamScanService.checkAndRemove(share.id);
if (reverseShareToken) {
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: { used: true },
});
}
return await this.prisma.share.update({
where: { id },
data: { uploadLocked: true },

View File

@ -100,7 +100,7 @@ const AdminConfigTable = () => {
<Space h="lg" />
</>
))}
{category == "email" && (
{category == "smtp" && (
<Group position="right">
<TestEmailButton />
</Group>

View File

@ -1,6 +1,6 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link";
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
@ -11,17 +11,10 @@ const ActionAvatar = () => {
<Menu position="bottom-start" withinPortal>
<Menu.Target>
<ActionIcon>
<Avatar size={28} radius="xl" />
<Avatar size={28} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
component={Link}
href="/account/shares"
icon={<TbLink size={14} />}
>
My shares
</Menu.Item>
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
My account
</Menu.Item>

View File

@ -1,4 +1,5 @@
import {
ActionIcon,
Box,
Burger,
Container,
@ -13,10 +14,12 @@ import {
import { useDisclosure } from "@mantine/hooks";
import Link from "next/link";
import { ReactNode, useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import Logo from "../Logo";
import ActionAvatar from "./ActionAvatar";
import NavbarShareMenu from "./NavbarShareMenu";
const HEADER_HEIGHT = 60;
@ -117,6 +120,9 @@ const NavBar = () => {
link: "/upload",
label: "Upload",
},
{
component: <NavbarShareMenu />,
},
{
component: <ActionAvatar />,
},

View File

@ -0,0 +1,29 @@
import { ActionIcon, Menu } from "@mantine/core";
import Link from "next/link";
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
const NavbarShareMneu = () => {
return (
<Menu position="bottom-start" withinPortal>
<Menu.Target>
<ActionIcon>
<TbLink />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item component={Link} href="/account/shares" icon={<TbLink />}>
My shares
</Menu.Item>
<Menu.Item
component={Link}
href="/account/reverseShares"
icon={<TbArrowLoopLeft />}
>
Reverse shares
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default NavbarShareMneu;

View File

@ -2,7 +2,7 @@ import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
import { TbCircleCheck, TbDownload } from "react-icons/tb";
import shareService from "../../services/share.service";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
const FileList = ({
files,
@ -28,7 +28,7 @@ const FileList = ({
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size)}</td>
<td>{byteToHumanSizeString(file.size)}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (

View File

@ -0,0 +1,62 @@
import { Col, Grid, NumberInput, Select } from "@mantine/core";
import { useEffect, useState } from "react";
import {
byteToUnitAndSize,
unitAndSizeToByte,
} from "../../utils/fileSize.util";
const FileSizeInput = ({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (number: number) => void;
}) => {
const [unit, setUnit] = useState("MB");
const [size, setSize] = useState(100);
useEffect(() => {
const { unit, size } = byteToUnitAndSize(value);
setUnit(unit);
setSize(size);
}, [value]);
return (
<Grid align="flex-end">
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label={label}
value={size}
onChange={(value) => {
setSize(value!);
onChange(unitAndSizeToByte(unit, value!));
}}
/>
</Col>
<Col xs={6}>
<Select
data={[
{ label: "B", value: "B" },
{ label: "KB", value: "KB" },
{ label: "MB", value: "MB" },
{ label: "GB", value: "GB" },
{ label: "TB", value: "TB" },
]}
value={unit}
onChange={(value) => {
setUnit(value!);
onChange(unitAndSizeToByte(value!, size));
}}
/>
</Col>
</Grid>
);
};
export default FileSizeInput;

View File

@ -0,0 +1,68 @@
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { TbCopy } from "react-icons/tb";
import toast from "../../../utils/toast.util";
const showCompletedReverseShareModal = (
modals: ModalsContextProps,
link: string,
getReverseShares: () => void
) => {
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: (
<Stack align="stretch" spacing={0}>
<Title order={4}>Reverse share link</Title>
</Stack>
),
children: <Body link={link} getReverseShares={getReverseShares} />,
});
};
const Body = ({
link,
getReverseShares,
}: {
link: string;
getReverseShares: () => void;
}) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals();
return (
<Stack align="stretch">
<TextInput
readOnly
variant="filled"
value={link}
rightSection={
window.isSecureContext && (
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<TbCopy />
</ActionIcon>
)
}
/>
<Button
onClick={() => {
modals.closeAll();
getReverseShares();
}}
>
Done
</Button>
</Stack>
);
};
export default showCompletedReverseShareModal;

View File

@ -0,0 +1,156 @@
import {
Button,
Col,
Grid,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import shareService from "../../../services/share.service";
import { getExpirationPreview } from "../../../utils/date.util";
import toast from "../../../utils/toast.util";
import FileSizeInput from "../FileSizeInput";
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
const showCreateReverseShareModal = (
modals: ModalsContextProps,
showSendEmailNotificationOption: boolean,
getReverseShares: () => void
) => {
return modals.openModal({
title: <Title order={4}>Create reverse share</Title>,
children: (
<Body
showSendEmailNotificationOption={showSendEmailNotificationOption}
getReverseShares={getReverseShares}
/>
),
});
};
const Body = ({
getReverseShares,
showSendEmailNotificationOption,
}: {
getReverseShares: () => void;
showSendEmailNotificationOption: boolean;
}) => {
const modals = useModals();
const form = useForm({
initialValues: {
maxShareSize: 104857600,
sendEmailNotification: false,
expiration_num: 1,
expiration_unit: "-days",
},
});
return (
<Group>
<form
onSubmit={form.onSubmit(async (values) => {
shareService
.createReverseShare(
values.expiration_num + values.expiration_unit,
values.maxShareSize,
values.sendEmailNotification
)
.then(({ link }) => {
modals.closeAll();
showCompletedReverseShareModal(modals, link, getReverseShares);
})
.catch(toast.axiosError);
})}
>
<Stack align="stretch">
<div>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Share expiration"
{...form.getInputProps("expiration_num")}
/>
</Col>
<Col xs={6}>
<Select
{...form.getInputProps("expiration_unit")}
data={[
// Set the label to singular if the number is 1, else plural
{
value: "-minutes",
label:
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-hours",
label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-days",
label:
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-weeks",
label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-months",
label:
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
},
]}
/>
</Col>
</Grid>
<Text
mt="sm"
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{getExpirationPreview("reverse share", form)}
</Text>
</div>
<FileSizeInput
label="Max share size"
value={form.values.maxShareSize}
onChange={(number) => form.setFieldValue("maxShareSize", number)}
/>
{showSendEmailNotificationOption && (
<Switch
mt="xs"
labelPosition="left"
label="Send email notification"
description="Send an email notification when a share is created with this reverse share link"
{...form.getInputProps("sendEmailNotification", {
type: "checkbox",
})}
/>
)}
<Button mt="md" type="submit">
Create
</Button>
</Stack>
</form>
</Group>
);
};
export default showCreateReverseShareModal;

View File

@ -4,7 +4,7 @@ import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb";
import useConfig from "../../hooks/config.hook";
import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util";
const useStyles = createStyles((theme) => ({
@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
const Dropzone = ({
isUploading,
maxShareSize,
files,
setFiles,
}: {
isUploading: boolean;
maxShareSize: number;
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => {
@ -58,10 +60,10 @@ const Dropzone = ({
0
);
if (fileSizeSum > config.get("MAX_SHARE_SIZE")) {
if (fileSizeSum > maxShareSize) {
toast.error(
`Your files exceed the maximum share size of ${byteStringToHumanSizeString(
config.get("MAX_SHARE_SIZE")
`Your files exceed the maximum share size of ${byteToHumanSizeString(
maxShareSize
)}.`
);
} else {
@ -84,9 +86,8 @@ const Dropzone = ({
</Text>
<Text align="center" size="sm" mt="xs" color="dimmed">
Drag&apos;n&apos;drop files here to start your share. We can accept
only files that are less than{" "}
{byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in
total.
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
in total.
</Text>
</div>
</MantineDropzone>

View File

@ -1,19 +0,0 @@
import moment from "moment";
const ExpirationPreview = ({ form }: { form: any }) => {
const value = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
if (value === "never") return "This share will never expire.";
const expirationDate = moment()
.add(
value.split("-")[0],
value.split("-")[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
};
export default ExpirationPreview;

View File

@ -2,7 +2,7 @@ import { ActionIcon, Table } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
import { TbTrash } from "react-icons/tb";
import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import UploadProgressIndicator from "./UploadProgressIndicator";
const FileList = ({
@ -19,7 +19,7 @@ const FileList = ({
const rows = files.map((file, i) => (
<tr key={i}>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size.toString())}</td>
<td>{byteToHumanSizeString(file.size)}</td>
<td>
{file.uploadingProgress == 0 ? (
<ActionIcon

View File

@ -5,7 +5,6 @@ import {
Checkbox,
Col,
Grid,
Group,
MultiSelect,
NumberInput,
PasswordInput,
@ -24,12 +23,13 @@ import { TbAlertCircle } from "react-icons/tb";
import * as yup from "yup";
import shareService from "../../../services/share.service";
import { CreateShare } from "../../../types/share.type";
import ExpirationPreview from "../ExpirationPreview";
import { getExpirationPreview } from "../../../utils/date.util";
const showCreateUploadModal = (
modals: ModalsContextProps,
options: {
isUserSignedIn: boolean;
isReverseShare: boolean;
appUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
@ -54,6 +54,7 @@ const CreateUploadModalBody = ({
uploadCallback: (createShare: CreateShare) => void;
options: {
isUserSignedIn: boolean;
isReverseShare: boolean;
appUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
@ -89,7 +90,7 @@ const CreateUploadModalBody = ({
validate: yupResolver(validationSchema),
});
return (
<Group>
<>
{showNotSignedInAlert && !options.isUserSignedIn && (
<Alert
withCloseButton
@ -161,72 +162,78 @@ const CreateUploadModalBody = ({
{options.appUrl}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Expiration"
placeholder="n"
disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")}
{!options.isReverseShare && (
<>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Expiration"
placeholder="n"
disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")}
/>
</Col>
<Col xs={6}>
<Select
disabled={form.values.never_expires}
{...form.getInputProps("expiration_unit")}
data={[
// Set the label to singular if the number is 1, else plural
{
value: "-minutes",
label:
"Minute" +
(form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-hours",
label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-days",
label:
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-weeks",
label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-months",
label:
"Month" +
(form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-years",
label:
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
},
]}
/>
</Col>
</Grid>
<Checkbox
label="Never Expires"
{...form.getInputProps("never_expires")}
/>
</Col>
<Col xs={6}>
<Select
disabled={form.values.never_expires}
{...form.getInputProps("expiration_unit")}
data={[
// Set the label to singular if the number is 1, else plural
{
value: "-minutes",
label:
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-hours",
label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-days",
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-weeks",
label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-months",
label:
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-years",
label:
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
},
]}
/>
</Col>
</Grid>
<Checkbox
label="Never Expires"
{...form.getInputProps("never_expires")}
/>
{/* Preview expiration date text */}
<Text
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{ExpirationPreview({ form })}
</Text>
<Text
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{getExpirationPreview("share", form)}
</Text>
</>
)}
<Accordion>
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
<Accordion.Control>Description</Accordion.Control>
@ -296,7 +303,7 @@ const CreateUploadModalBody = ({
<Button type="submit">Share</Button>
</Stack>
</form>
</Group>
</>
);
};

View File

@ -0,0 +1,200 @@
import {
ActionIcon,
Box,
Button,
Center,
Group,
LoadingOverlay,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import moment from "moment";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal";
import Meta from "../../components/Meta";
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service";
import { MyReverseShare } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util";
const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
const config = useConfig();
const { user } = useUser();
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
const getReverseShares = () => {
shareService
.getMyReverseShares()
.then((shares) => setReverseShares(shares));
};
useEffect(() => {
getReverseShares();
}, []);
if (!user) {
router.replace("/");
} else {
if (!reverseShares) return <LoadingOverlay visible />;
return (
<>
<Meta title="My shares" />
<Group position="apart" align="baseline" mb={20}>
<Group align="center" spacing={3} mb={30}>
<Title order={3}>My reverse shares</Title>
<Tooltip
position="bottom"
multiline
width={220}
label="A reverse share allows you to generate a unique URL for a single-use share for an external user."
events={{ hover: true, focus: false, touch: true }}
>
<ActionIcon>
<TbInfoCircle />
</ActionIcon>
</Tooltip>
</Group>
<Button
onClick={() =>
showCreateReverseShareModal(
modals,
config.get("SMTP_ENABLED"),
getReverseShares
)
}
leftIcon={<TbPlus size={20} />}
>
Create
</Button>
</Group>
{reverseShares.length == 0 ? (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Title order={3}>It's empty here 👀</Title>
<Text>You don't have any reverse shares.</Text>
</Stack>
</Center>
) : (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Visitors</th>
<th>Max share size</th>
<th>Expires at</th>
<th></th>
</tr>
</thead>
<tbody>
{reverseShares.map((reverseShare) => (
<tr key={reverseShare.id}>
<td>
{reverseShare.share ? (
reverseShare.share?.id
) : (
<Text color="dimmed">No share created yet</Text>
)}
</td>
<td>{reverseShare.share?.views ?? "0"}</td>
<td>
{byteToHumanSizeString(
parseInt(reverseShare.maxShareSize)
)}
</td>
<td>
{moment(reverseShare.shareExpiration).unix() === 0
? "Never"
: moment(reverseShare.shareExpiration).format("LLL")}
</td>
<td>
<Group position="right">
{reverseShare.share && (
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${
reverseShare.share!.id
}`
);
toast.success(
"The share link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
reverseShare.share!.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
)}
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete reverse share`,
children: (
<Text size="sm">
Do you really want to delete this reverse
share? If you do, the share will be deleted as
well.
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.removeReverseShare(
reverseShare.id
);
setReverseShares(
reverseShares.filter(
(item) => item.id !== reverseShare.id
)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);
}
};
export default MyShares;

View File

@ -1,5 +1,6 @@
import {
ActionIcon,
Box,
Button,
Center,
Group,
@ -61,83 +62,85 @@ const MyShares = () => {
</Stack>
</Center>
) : (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Visitors</th>
<th>Expires at</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Visitors</th>
<th>Expires at</th>
<th></th>
</tr>
))}
</tbody>
</Table>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)}
</>
);

View File

@ -0,0 +1,43 @@
import { LoadingOverlay } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next";
import { useEffect, useState } from "react";
import Upload from ".";
import showErrorModal from "../../components/share/showErrorModal";
import shareService from "../../services/share.service";
export function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: { reverseShareToken: context.params!.reverseShareToken },
};
}
const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
const modals = useModals();
const [isLoading, setIsLoading] = useState(true);
const [maxShareSize, setMaxShareSize] = useState(0);
useEffect(() => {
shareService
.setReverseShare(reverseShareToken)
.then((reverseShareTokenData) => {
setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize));
setIsLoading(false);
})
.catch(() => {
showErrorModal(
modals,
"Invalid Link",
"This link is invalid. Please check your link."
);
setIsLoading(false);
});
}, []);
if (isLoading) return <LoadingOverlay visible />;
return <Upload isReverseShare maxShareSize={maxShareSize} />;
};
export default Share;

View File

@ -2,27 +2,34 @@ import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { cleanNotifications } from "@mantine/notifications";
import { AxiosError } from "axios";
import { getCookie } from "cookies-next";
import { useRouter } from "next/router";
import pLimit from "p-limit";
import { useEffect, useState } from "react";
import Meta from "../components/Meta";
import Dropzone from "../components/upload/Dropzone";
import FileList from "../components/upload/FileList";
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
import useConfig from "../hooks/config.hook";
import useUser from "../hooks/user.hook";
import shareService from "../services/share.service";
import { FileUpload } from "../types/File.type";
import { CreateShare, Share } from "../types/share.type";
import toast from "../utils/toast.util";
import Meta from "../../components/Meta";
import Dropzone from "../../components/upload/Dropzone";
import FileList from "../../components/upload/FileList";
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
import showCreateUploadModal from "../../components/upload/modals/showCreateUploadModal";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service";
import { FileUpload } from "../../types/File.type";
import { CreateShare, Share } from "../../types/share.type";
import toast from "../../utils/toast.util";
const promiseLimit = pLimit(3);
const chunkSize = 10 * 1024 * 1024; // 10MB
let errorToastShown = false;
let createdShare: Share;
const Upload = () => {
const Upload = ({
maxShareSize,
isReverseShare = false,
}: {
maxShareSize?: number;
isReverseShare: boolean;
}) => {
const router = useRouter();
const modals = useModals();
@ -31,6 +38,8 @@ const Upload = () => {
const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false);
maxShareSize ??= parseInt(config.get("MAX_SHARE_SIZE"));
const uploadFiles = async (share: CreateShare) => {
setisUploading(true);
createdShare = await shareService.create(share);
@ -138,9 +147,9 @@ const Upload = () => {
) {
shareService
.completeShare(createdShare.id)
.then(() => {
.then((share) => {
setisUploading(false);
showCompletedUploadModal(modals, createdShare, config.get("APP_URL"));
showCompletedUploadModal(modals, share, config.get("APP_URL"));
setFiles([]);
})
.catch(() =>
@ -149,8 +158,13 @@ const Upload = () => {
}
}, [files]);
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
if (
!user &&
!config.get("ALLOW_UNAUTHENTICATED_SHARES") &&
!getCookie("reverse_share_token")
) {
router.replace("/");
return null;
} else {
return (
<>
@ -164,11 +178,14 @@ const Upload = () => {
modals,
{
isUserSignedIn: user ? true : false,
isReverseShare,
appUrl: config.get("APP_URL"),
allowUnauthenticatedShares: config.get(
"ALLOW_UNAUTHENTICATED_SHARES"
),
enableEmailRecepients: config.get("ENABLE_EMAIL_RECIPIENTS"),
enableEmailRecepients: config.get(
"ENABLE_SHARE_EMAIL_RECIPIENTS"
),
},
uploadFiles
);
@ -177,7 +194,12 @@ const Upload = () => {
Share
</Button>
</Group>
<Dropzone files={files} setFiles={setFiles} isUploading={isUploading} />
<Dropzone
maxShareSize={maxShareSize}
files={files}
setFiles={setFiles}
isUploading={isUploading}
/>
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
</>
);

View File

@ -1,6 +1,8 @@
import { setCookie } from "cookies-next";
import { FileUploadResponse } from "../types/File.type";
import {
CreateShare,
MyReverseShare,
MyShare,
Share,
ShareMetaData,
@ -98,6 +100,34 @@ const uploadFile = async (
).data;
};
const createReverseShare = async (
shareExpiration: string,
maxShareSize: number,
sendEmailNotification: boolean
) => {
return (
await api.post("reverseShares", {
shareExpiration,
maxShareSize: maxShareSize.toString(),
sendEmailNotification,
})
).data;
};
const getMyReverseShares = async (): Promise<MyReverseShare[]> => {
return (await api.get("reverseShares")).data;
};
const setReverseShare = async (reverseShareToken: string) => {
const { data } = await api.get(`/reverseShares/${reverseShareToken}`);
setCookie("reverse_share_token", reverseShareToken);
return data;
};
const removeReverseShare = async (id: string) => {
await api.delete(`/reverseShares/${id}`);
};
export default {
create,
completeShare,
@ -109,4 +139,8 @@ export default {
isShareIdAvailable,
downloadFile,
uploadFile,
setReverseShare,
createReverseShare,
getMyReverseShares,
removeReverseShare,
};

View File

@ -26,6 +26,13 @@ export type MyShare = Share & {
cratedAt: Date;
};
export type MyReverseShare = {
id: string;
maxShareSize: string;
shareExpiration: Date;
share?: MyShare;
};
export type ShareSecurity = {
maxViews?: number;
password?: string;

View File

@ -0,0 +1,26 @@
import moment from "moment";
export const getExpirationPreview = (
name: string,
form: {
values: {
never_expires?: boolean;
expiration_num: number;
expiration_unit: string;
};
}
) => {
const value = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
if (value === "never") return `This ${name} will never expire.`;
const expirationDate = moment()
.add(
value.split("-")[0],
value.split("-")[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
return `This ${name} will expire on ${moment(expirationDate).format("LLL")}`;
};

View File

@ -0,0 +1,23 @@
export function byteToHumanSizeString(bytes: number) {
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes == 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return (bytes / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i];
}
export function byteToUnitAndSize(bytes: number) {
const units = ["B", "KB", "MB", "GB", "TB"];
if (bytes == 0) return { unit: "B", size: 0 };
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
return {
size: parseFloat((bytes / Math.pow(1024, i)).toFixed(1)),
unit: units[i],
};
}
export function unitAndSizeToByte(unit: string, size: number) {
const units = ["B", "KB", "MB", "GB", "TB"];
const i = units.indexOf(unit);
return Math.pow(1024, i) * size;
}

View File

@ -1,11 +0,0 @@
export function byteStringToHumanSizeString(bytes: string) {
const bytesNumber = parseInt(bytes);
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytesNumber == 0) return "0 Byte";
const i = parseInt(
Math.floor(Math.log(bytesNumber) / Math.log(1024)).toString()
);
return (
(bytesNumber / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i]
);
}