Compare commits

..

14 Commits

Author SHA1 Message Date
Elias Schneider
3d5c919110 release: 0.9.0 2023-01-31 15:25:01 +01:00
Elias Schneider
008df06b5c feat: direct file link 2023-01-31 15:22:08 +01:00
Elias Schneider
cd9d828686 refactor: move guard checks to service 2023-01-31 13:53:23 +01:00
Elias Schneider
233c26e5cf fix: improve send test email UX 2023-01-31 13:16:11 +01:00
Elias Schneider
91a6b3f716 feat: file preview 2023-01-31 09:03:03 +01:00
Elias Schneider
0a2b7b1243 refactor: use cookie instead of local storage for share token 2023-01-26 21:18:22 +01:00
Elias Schneider
b98fe7911f release: 0.8.0 2023-01-26 16:10:16 +01:00
Elias Schneider
ad92cfc852 fix: admin users were created while the setup wizard wasn't finished 2023-01-26 15:43:13 +01:00
Elias Schneider
7e91038a24 chore: optimize prisma migration 2023-01-26 14:06:25 +01:00
Elias Schneider
4a5fb549c6 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
2023-01-26 13:44:04 +01:00
Elias Schneider
1ceb07b89e refactor: fix typo of service name 2023-01-17 09:48:49 +01:00
Elias Schneider
bb64f6c33f fix: Add meta tags to new pages 2023-01-17 09:13:53 +01:00
Elias Schneider
61c48d57b8 ci/cd: upgrade github actions 2023-01-13 15:37:49 +01:00
Luke
2a7587ed78 chore: docker compose ClamAV optimizations
* Update docker-compose.yml

Adds a depends_on clause that waits for clamav to be fulyl started before starting pingvin-share.

* Update README.md

Explains that it may take a minute or two for the app to start while it waits for clamav.

* minor refactoring

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-01-13 14:11:33 +01:00
72 changed files with 2177 additions and 727 deletions

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
container: node:18
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install Dependencies
working-directory: ./backend
run: npm install

View File

@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: login to docker registry
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build the image

View File

@@ -1,3 +1,29 @@
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
### Features
* direct file link ([008df06](https://github.com/stonith404/pingvin-share/commit/008df06b5cf48872d4dd68df813370596a4fd468))
* file preview ([91a6b3f](https://github.com/stonith404/pingvin-share/commit/91a6b3f716d37d7831e17a7be1cdb35cb23da705))
### Bug Fixes
* improve send test email UX ([233c26e](https://github.com/stonith404/pingvin-share/commit/233c26e5cfde59e7d51023ef9901dec2b84a4845))
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
### Features
* reverse shares ([#86](https://github.com/stonith404/pingvin-share/issues/86)) ([4a5fb54](https://github.com/stonith404/pingvin-share/commit/4a5fb549c6ac808261eb65d28db69510a82efd00))
### Bug Fixes
* Add meta tags to new pages ([bb64f6c](https://github.com/stonith404/pingvin-share/commit/bb64f6c33fc5c5e11f2c777785c96a74b57dfabc))
* admin users were created while the setup wizard wasn't finished ([ad92cfc](https://github.com/stonith404/pingvin-share/commit/ad92cfc852ca6aa121654d747a02628492ae5b89))
## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13)

View File

@@ -36,7 +36,7 @@ The website is now listening available on `http://localhost:3000`, have fun with
With ClamAV the shares get scanned for malicious files and get removed if any found.
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
2. As soon as the ClamAV container is ready (when ClamAV logs "socket found, clamd started"), restart the Pingvin Share container with `docker compose restart pingvin-share`
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
3. The Pingvin Share logs should now log "ClamAV is active"
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).

View File

@@ -1,12 +1,12 @@
{
"name": "pingvin-share-backend",
"version": "0.7.0",
"version": "0.9.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-backend",
"version": "0.7.0",
"version": "0.9.0",
"dependencies": {
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
@@ -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

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-backend",
"version": "0.7.0",
"version": "0.9.0",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
@@ -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,67 @@
/*
Warnings:
- Added the required column `order` 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" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"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,
"order" INTEGER NOT NULL
);
INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "order") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", 0 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");
-- Custom migration
UPDATE Config SET `order` = 0 WHERE key = "JWT_SECRET";
UPDATE Config SET `order` = 0 WHERE key = "TOTP_SECRET";
UPDATE Config SET `order` = 1 WHERE key = "APP_URL";
UPDATE Config SET `order` = 2 WHERE key = "SHOW_HOME_PAGE";
UPDATE Config SET `order` = 3 WHERE key = "ALLOW_REGISTRATION";
UPDATE Config SET `order` = 4 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE Config SET `order` = 5 WHERE key = "MAX_SHARE_SIZE";
UPDATE Config SET `order` = 6, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE Config SET `order` = 7, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE";
UPDATE Config SET `order` = 8, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT";
UPDATE Config SET `order` = 12 WHERE key = "SMTP_HOST";
UPDATE Config SET `order` = 13 WHERE key = "SMTP_PORT";
UPDATE Config SET `order` = 14 WHERE key = "SMTP_EMAIL";
UPDATE Config SET `order` = 15 WHERE key = "SMTP_USERNAME";
UPDATE Config SET `order` = 16 WHERE key = "SMTP_PASSWORD";
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (11, "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'));
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`, `locked`) VALUES (0, "SETUP_STATUS", "Status of the setup wizard", "string", IIF((SELECT value FROM Config WHERE key="SETUP_FINISHED") == "true", "FINISHED", "STARTED"), "internal", 0, strftime('%s', 'now'), 1);

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
@@ -107,4 +128,5 @@ model Config {
obscured Boolean @default(false)
secret Boolean @default(true)
locked Boolean @default(false)
order Int
}

View File

@@ -3,55 +3,17 @@ import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [
{
key: "SETUP_FINISHED",
description: "Whether the setup has been finished",
type: "boolean",
value: "false",
order: 0,
key: "SETUP_STATUS",
description: "Status of the setup wizard",
type: "string",
value: "STARTED", // STARTED, REGISTERED, FINISHED
category: "internal",
secret: false,
locked: true,
},
{
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
category: "general",
secret: false,
},
{
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
category: "general",
secret: false,
},
{
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
category: "share",
secret: false,
},
{
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
key: "MAX_SHARE_SIZE",
description: "Maximum share size in bytes",
type: "number",
value: "1073741824",
category: "share",
secret: false,
},
{
order: 0,
key: "JWT_SECRET",
description: "Long random string used to sign JWT tokens",
type: "string",
@@ -60,6 +22,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
locked: true,
},
{
order: 0,
key: "TOTP_SECRET",
description: "A 16 byte random string used to generate TOTP secrets",
type: "string",
@@ -68,65 +31,150 @@ const configVariables: Prisma.ConfigCreateInput[] = [
locked: true,
},
{
key: "ENABLE_EMAIL_RECIPIENTS",
order: 1,
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
category: "general",
secret: false,
},
{
order: 2,
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
category: "general",
secret: false,
},
{
order: 3,
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
category: "share",
secret: false,
},
{
order: 4,
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
order: 5,
key: "MAX_SHARE_SIZE",
description: "Maximum share size in bytes",
type: "number",
value: "1073741824",
category: "share",
secret: false,
},
{
order: 6,
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",
order: 7,
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.",
order: 8,
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",
},
{
order: 9,
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",
},
{
order: 10,
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",
},
{
order: 11,
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,
},
{
order: 12,
key: "SMTP_HOST",
description: "Host of the SMTP server",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
order: 13,
key: "SMTP_PORT",
description: "Port of the SMTP server",
type: "number",
value: "0",
category: "email",
category: "smtp",
},
{
order: 14,
key: "SMTP_EMAIL",
description: "Email address which the emails get sent from",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
order: 15,
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
category: "email",
category: "smtp",
},
{
order: 16,
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
category: "email",
category: "smtp",
},
];

View File

@@ -12,7 +12,8 @@ import { JobsModule } from "./jobs/jobs.module";
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 { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
@Module({
imports: [
@@ -29,7 +30,8 @@ import { ClamscanModule } from "./clamscan/clamscan.module";
limit: 100,
}),
ScheduleModule.forRoot(),
ClamscanModule,
ClamScanModule,
ReverseShareModule,
],
providers: [
{

View File

@@ -42,6 +42,7 @@ export class AuthController {
) {
if (!this.config.get("ALLOW_REGISTRATION"))
throw new ForbiddenException("Registration is not allowed");
const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(

View File

@@ -23,6 +23,8 @@ export class AuthService {
) {}
async signUp(dto: AuthRegisterDTO) {
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
const hash = await argon.hash(dto.password);
try {
const user = await this.prisma.user.create({
@@ -30,10 +32,14 @@ export class AuthService {
email: dto.email,
username: dto.username,
password: hash,
isAdmin: !this.config.get("SETUP_FINISHED"),
isAdmin: isFirstUser,
},
});
if (isFirstUser) {
await this.config.changeSetupStatus("REGISTERED");
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);

View File

@@ -7,4 +7,4 @@ import { ClamScanService } from "./clamscan.service";
providers: [ClamScanService],
exports: [ClamScanService],
})
export class ClamscanModule {}
export class ClamScanModule {}

View File

@@ -37,7 +37,7 @@ export class ConfigController {
@Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() {
return await this.configService.finishSetup();
return await this.configService.changeSetupStatus("FINISHED");
}
@Post("admin/testEmail")

View File

@@ -29,6 +29,7 @@ export class ConfigService {
async listForAdmin() {
return await this.prisma.config.findMany({
orderBy: { order: "asc" },
where: { locked: { equals: false } },
});
}
@@ -75,10 +76,10 @@ export class ConfigService {
return updatedVariable;
}
async finishSetup() {
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
return await this.prisma.config.update({
where: { key: "SETUP_FINISHED" },
data: { value: "true" },
where: { key: "SETUP_STATUS" },
data: { value: status },
});
}
}

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,21 +35,40 @@ 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),
});
}
async sendTestMail(recipientEmail: string) {
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
});
try {
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
});
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e.message);
}
}
}

View File

@@ -12,11 +12,10 @@ import {
import { SkipThrottle } from "@nestjs/throttler";
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";
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
@Controller("shares/:shareId/files")
export class FileController {
@@ -24,7 +23,7 @@ export class FileController {
@Post()
@SkipThrottle()
@UseGuards(JwtGuard, ShareOwnerGuard)
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async create(
@Query() query: any,
@@ -43,30 +42,8 @@ export class FileController {
);
}
@Get(":fileId/download")
@UseGuards(ShareSecurityGuard)
async getFileDownloadUrl(
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip/download")
@UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL(
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip")
@UseGuards(FileDownloadGuard)
@UseGuards(FileSecurityGuard)
async getZip(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string
@@ -74,25 +51,32 @@ export class FileController {
const zip = this.fileService.getZip(shareId);
res.set({
"Content-Type": "application/zip",
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
});
return new StreamableFile(zip);
}
@Get(":fileId")
@UseGuards(FileDownloadGuard)
@UseGuards(FileSecurityGuard)
async getFile(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
@Param("fileId") fileId: string,
@Query("download") download = "true"
) {
const file = await this.fileService.get(shareId, fileId);
res.set({
const headers = {
"Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size,
"Content-Disposition": contentDisposition(file.metaData.name),
});
};
if (download === "true") {
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
}
res.set(headers);
return new StreamableFile(file.file);
}

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",
@@ -132,38 +135,4 @@ export class FileService {
getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
}
getFileDownloadUrl(shareId: string, fileId: string) {
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
return `${this.config.get(
"APP_URL"
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
}
generateFileDownloadToken(shareId: string, fileId: string) {
if (fileId == "zip") fileId = undefined;
return this.jwtService.sign(
{
shareId,
fileId,
},
{
expiresIn: "10min",
secret: this.config.get("JWT_SECRET"),
}
);
}
verifyFileDownloadToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId;
} catch {
return false;
}
}
}

View File

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

View File

@@ -0,0 +1,65 @@
import {
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { ShareService } from "src/share/share.service";
@Injectable()
export class FileSecurityGuard extends ShareSecurityGuard {
constructor(
private _shareService: ShareService,
private _prisma: PrismaService
) {
super(_shareService, _prisma);
}
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 shareToken = request.cookies[`share_${shareId}_token`];
const share = await this._prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
});
// If there is no share token the user requests a file directly
if (!shareToken) {
if (
!share ||
(moment().isAfter(share.expiration) &&
!moment(share.expiration).isSame(0))
) {
throw new NotFoundException("File not found");
}
if (share.security?.password)
throw new ForbiddenException("This share is password protected");
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
}
await this._shareService.increaseViewCount(share);
return true;
} else {
return super.canActivate(context);
}
}
}

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,26 @@
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", "hasPassword"] as const))
share: Omit<
MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword"
>;
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

@@ -20,6 +20,9 @@ export class ShareDTO {
@Expose()
description: string;
@Expose()
hasPassword: boolean;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
}

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

@@ -5,7 +5,6 @@ import {
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";
@@ -14,14 +13,13 @@ 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 shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
? request.params.shareId
: request.params.id;
const shareToken = request.cookies[`share_${shareId}_token`];
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
if (
!share ||
(moment().isAfter(share.expiration) &&
moment(share.expiration).unix() !== 0)
!moment(share.expiration).isSame(0))
)
throw new NotFoundException("Share not found");

View File

@@ -1,7 +1,6 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
)
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

@@ -6,10 +6,13 @@ import {
HttpCode,
Param,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto";
@@ -17,6 +20,7 @@ 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";
@@ -46,9 +50,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,21 +70,35 @@ 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);
}
@HttpCode(200)
@Throttle(10, 5 * 60)
@Throttle(20, 5 * 60)
@UseGuards(ShareTokenSecurity)
@Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
return this.shareService.getShareToken(id, body.password);
async getShareToken(
@Param("id") id: string,
@Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto
) {
const token = await this.shareService.getShareToken(id, body.password);
response.cookie(`share_${id}_token`, token, {
path: "/",
httpOnly: true,
});
return { token };
}
}

View File

@@ -1,8 +1,9 @@
import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ClamscanModule } from "src/clamscan/clamscan.module";
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";
@@ -10,7 +11,8 @@ import { ShareService } from "./share.service";
imports: [
JwtModule.register({}),
EmailModule,
ClamscanModule,
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 clasmScanService: ClamScanService
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,15 +143,33 @@ 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.clasmScanService.checkAndRemove(share.id);
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 },
@@ -161,12 +204,13 @@ export class ShareService {
return sharesWithEmailRecipients;
}
async get(id: string) {
async get(id: string): Promise<any> {
const share = await this.prisma.share.findUnique({
where: { id },
include: {
files: true,
creator: true,
security: true,
},
});
@@ -175,8 +219,10 @@ export class ShareService {
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
return share as any;
return {
...share,
hasPassword: share.security?.password ? true : false,
};
}
async getMetaData(id: string) {
@@ -230,12 +276,20 @@ export class ShareService {
if (
share?.security?.password &&
!(await argon.verify(share.security.password, password))
)
) {
throw new ForbiddenException("Wrong password");
}
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
}
const token = await this.generateShareToken(shareId);
await this.increaseViewCount(share);
return { token };
return token;
}
async generateShareToken(shareId: string) {

View File

@@ -7,8 +7,12 @@ services:
- 3000:3000
volumes:
- "./data:/opt/app/backend/data"
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
# depends_on:
# clamav:
# condition: service_healthy
# Optional: Add ClamAV (see README.md)
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
# clamav:
# restart: unless-stopped
# image: clamav/clamav
# image: clamav/clamav

View File

@@ -1,12 +1,12 @@
{
"name": "pingvin-share-frontend",
"version": "0.7.0",
"version": "0.9.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-frontend",
"version": "0.7.0",
"version": "0.9.0",
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0",
@@ -21,6 +21,7 @@
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.11.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.1.2",
"next-cookies": "^2.0.3",
@@ -33,6 +34,7 @@
"yup": "^0.32.11"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
@@ -2656,6 +2658,12 @@
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
},
"node_modules/@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
"dev": true
},
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@@ -9913,6 +9921,12 @@
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
},
"@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
"dev": true
},
"@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-frontend",
"version": "0.7.0",
"version": "0.9.0",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -22,6 +22,7 @@
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.11.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.1.2",
"next-cookies": "^2.0.3",
@@ -34,6 +35,7 @@
"yup": "^0.32.11"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",

View File

@@ -29,7 +29,9 @@ const AdminConfigTable = () => {
const config = useConfig();
const isMobile = useMediaQuery("(max-width: 560px)");
let updatedConfigVariables: UpdateConfig[] = [];
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
@@ -38,7 +40,7 @@ const AdminConfigTable = () => {
if (index > -1) {
updatedConfigVariables[index] = configVariable;
} else {
updatedConfigVariables.push(configVariable);
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
}
};
@@ -60,6 +62,26 @@ const AdminConfigTable = () => {
});
};
const saveConfigVariables = async () => {
if (config.get("SETUP_STATUS") == "REGISTERED") {
await configService
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
window.location.reload();
})
.catch(toast.axiosError);
} else {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
}
};
useEffect(() => {
getConfigVariables();
}, []);
@@ -100,9 +122,12 @@ const AdminConfigTable = () => {
<Space h="lg" />
</>
))}
{category == "email" && (
{category == "smtp" && (
<Group position="right">
<TestEmailButton />
<TestEmailButton
configVariablesChanged={updatedConfigVariables.length != 0}
saveConfigVariables={saveConfigVariables}
/>
</Group>
)}
</Paper>
@@ -110,29 +135,7 @@ const AdminConfigTable = () => {
}
)}
<Group position="right">
<Button
onClick={() => {
if (config.get("SETUP_FINISHED")) {
configService
.updateMany(updatedConfigVariables)
.then(() => {
updatedConfigVariables = [];
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
} else {
configService
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
window.location.reload();
})
.catch(toast.axiosError);
}
}}
>
Save
</Button>
<Button onClick={saveConfigVariables}>Save</Button>
</Group>
</Box>
);

View File

@@ -1,24 +1,69 @@
import { Button } from "@mantine/core";
import { Button, Stack, Text, Textarea } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useState } from "react";
import useUser from "../../../hooks/user.hook";
import configService from "../../../services/config.service";
import toast from "../../../utils/toast.util";
const TestEmailButton = () => {
const TestEmailButton = ({
configVariablesChanged,
saveConfigVariables,
}: {
configVariablesChanged: boolean;
saveConfigVariables: () => Promise<void>;
}) => {
const { user } = useUser();
const modals = useModals();
const [isLoading, setIsLoading] = useState(false);
const sendTestEmail = async () => {
await configService
.sendTestEmail(user!.email)
.then(() => toast.success("Email sent successfully"))
.catch((e) =>
modals.openModal({
title: "Failed to send email",
children: (
<Stack spacing="xs">
<Text size="sm">
While sending the test email, the following error occurred:
</Text>
<Textarea minRows={4} readOnly value={e.response.data.message} />
</Stack>
),
})
);
};
return (
<Button
loading={isLoading}
variant="light"
onClick={() =>
configService
.sendTestEmail(user!.email)
.then(() => toast.success("Email sent successfully"))
.catch(() =>
toast.error(
"Failed to send the email. Please check the backend logs for more information."
)
)
}
onClick={async () => {
if (!configVariablesChanged) {
setIsLoading(true);
await sendTestEmail();
setIsLoading(false);
} else {
modals.openConfirmModal({
title: "Save configuration",
children: (
<Text size="sm">
To continue you need to save the configuration first. Do you
want to save the configuration and send the test email?
</Text>
),
labels: { confirm: "Save and send", cancel: "Cancel" },
onConfirm: async () => {
setIsLoading(true);
await saveConfigVariables();
await sendTestEmail();
setIsLoading(false);
},
});
}
}}
>
Send test email
</Button>

View File

@@ -0,0 +1,13 @@
import { Center, Loader, Stack } from "@mantine/core";
const CenterLoader = () => {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Loader />
</Stack>
</Center>
);
};
export default CenterLoader;

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

@@ -1,18 +1,57 @@
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
import { TbCircleCheck, TbDownload } from "react-icons/tb";
import shareService from "../../services/share.service";
import {
ActionIcon,
Group,
Skeleton,
Stack,
Table,
TextInput,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import mime from "mime-types";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import Link from "next/link";
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
import useConfig from "../../hooks/config.hook";
import shareService from "../../services/share.service";
import { FileMetaData } from "../../types/File.type";
import { Share } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util";
const FileList = ({
files,
shareId,
share,
isLoading,
}: {
files?: any[];
shareId: string;
files?: FileMetaData[];
share: Share;
isLoading: boolean;
}) => {
const clipboard = useClipboard();
const config = useConfig();
const modals = useModals();
const copyFileLink = (file: FileMetaData) => {
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${
file.id
}`;
if (window.isSecureContext) {
clipboard.copy(link);
toast.success("Your file link was copied to the keyboard.");
} else {
modals.openModal({
title: "File link",
children: (
<Stack align="stretch">
<TextInput variant="filled" value={link} />
</Stack>
),
});
}
};
return (
<Table>
<thead>
@@ -28,24 +67,35 @@ const FileList = ({
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size)}</td>
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (
<Loader size={22} />
) : (
<TbCircleCheck color="green" size={22} />
)
) : (
<Group position="right">
{shareService.doesFileSupportPreview(file.name) && (
<ActionIcon
component={Link}
href={`/share/${share.id}/preview/${
file.id
}?type=${mime.contentType(file.name)}`}
target="_blank"
size={25}
>
<TbEye />
</ActionIcon>
)}
{!share.hasPassword && (
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
<TbLink />
</ActionIcon>
)}
<ActionIcon
size={25}
onClick={async () => {
await shareService.downloadFile(shareId, file.id);
await shareService.downloadFile(share.id, file.id);
}}
>
<TbDownload />
</ActionIcon>
)}
</Group>
</td>
</tr>
))}

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

@@ -46,15 +46,24 @@ function App({ Component, pageProps }: AppProps) {
getInitalData();
}, []);
// Redirect to setup page if setup is not completed
useEffect(() => {
if (
configVariables &&
configVariables.filter((variable) => variable.key)[0].value == "false" &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
router.push(!user ? "/auth/signUp" : "/admin/setup");
const setupStatus = configVariables.filter(
(variable) => variable.key == "SETUP_STATUS"
)[0].value;
if (setupStatus == "STARTED") {
router.replace("/auth/signUp");
} else if (user && setupStatus == "REGISTERED") {
router.replace("/admin/setup");
} else if (setupStatus == "REGISTERED") {
router.replace("/auth/signIn");
}
}
}, [router.asPath]);
}, [configVariables, router.asPath]);
useEffect(() => {
setColorScheme(

View File

@@ -18,6 +18,7 @@ import { Tb2Fa } from "react-icons/tb";
import * as yup from "yup";
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import userService from "../../services/user.service";
@@ -90,186 +91,192 @@ const Account = () => {
}
return (
<Container size="sm">
<Title order={3} mb="xs">
My account
</Title>
<Paper withBorder p="xl">
<Title order={5} mb="xs">
Account Info
<>
<Meta title="My account" />
<Container size="sm">
<Title order={3} mb="xs">
My account
</Title>
<form
onSubmit={accountForm.onSubmit((values) =>
userService
.updateCurrentUser({
username: values.username,
email: values.email,
})
.then(() => toast.success("User updated successfully"))
.catch(toast.axiosError)
)}
>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput label="Email" {...accountForm.getInputProps("email")} />
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Password
</Title>
<form
onSubmit={passwordForm.onSubmit((values) =>
authService
.updatePassword(values.oldPassword, values.password)
.then(() => {
toast.success("Password updated successfully");
passwordForm.reset();
})
.catch(toast.axiosError)
)}
>
<Stack>
<PasswordInput
label="Old password"
{...passwordForm.getInputProps("oldPassword")}
/>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Security
</Title>
<Tabs defaultValue="totp">
<Tabs.List>
<Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
TOTP
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? (
<>
<form
onSubmit={disableTotpForm.onSubmit((values) => {
authService
.disableTOTP(values.code, values.password)
.then(() => {
toast.success("Successfully disabled TOTP");
values.password = "";
values.code = "";
refreshUser();
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
description="Enter your current password to disable TOTP"
label="Password"
{...disableTotpForm.getInputProps("password")}
/>
<TextInput
variant="filled"
label="Code"
placeholder="******"
{...disableTotpForm.getInputProps("code")}
/>
<Group position="right">
<Button color="red" type="submit">
Disable
</Button>
</Group>
</Stack>
</form>
</>
) : (
<>
<form
onSubmit={enableTotpForm.onSubmit((values) => {
authService
.enableTOTP(values.password)
.then((result) => {
showEnableTotpModal(modals, refreshUser, {
qrCode: result.qrCode,
secret: result.totpSecret,
password: values.password,
});
values.password = "";
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="Password"
description="Enter your current password to start enabling TOTP"
{...enableTotpForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Start</Button>
</Group>
</Stack>
</form>
</>
<Paper withBorder p="xl">
<Title order={5} mb="xs">
Account Info
</Title>
<form
onSubmit={accountForm.onSubmit((values) =>
userService
.updateCurrentUser({
username: values.username,
email: values.email,
})
.then(() => toast.success("User updated successfully"))
.catch(toast.axiosError)
)}
</Tabs.Panel>
</Tabs>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Color scheme
</Title>
<ThemeSwitcher />
</Paper>
<Center mt={80} mb="lg">
<Stack>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all your
active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Stack>
</Center>
</Container>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput
label="Email"
{...accountForm.getInputProps("email")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Password
</Title>
<form
onSubmit={passwordForm.onSubmit((values) =>
authService
.updatePassword(values.oldPassword, values.password)
.then(() => {
toast.success("Password updated successfully");
passwordForm.reset();
})
.catch(toast.axiosError)
)}
>
<Stack>
<PasswordInput
label="Old password"
{...passwordForm.getInputProps("oldPassword")}
/>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Security
</Title>
<Tabs defaultValue="totp">
<Tabs.List>
<Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
TOTP
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? (
<>
<form
onSubmit={disableTotpForm.onSubmit((values) => {
authService
.disableTOTP(values.code, values.password)
.then(() => {
toast.success("Successfully disabled TOTP");
values.password = "";
values.code = "";
refreshUser();
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
description="Enter your current password to disable TOTP"
label="Password"
{...disableTotpForm.getInputProps("password")}
/>
<TextInput
variant="filled"
label="Code"
placeholder="******"
{...disableTotpForm.getInputProps("code")}
/>
<Group position="right">
<Button color="red" type="submit">
Disable
</Button>
</Group>
</Stack>
</form>
</>
) : (
<>
<form
onSubmit={enableTotpForm.onSubmit((values) => {
authService
.enableTOTP(values.password)
.then((result) => {
showEnableTotpModal(modals, refreshUser, {
qrCode: result.qrCode,
secret: result.totpSecret,
password: values.password,
});
values.password = "";
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="Password"
description="Enter your current password to start enabling TOTP"
{...enableTotpForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Start</Button>
</Group>
</Stack>
</form>
</>
)}
</Tabs.Panel>
</Tabs>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Color scheme
</Title>
<ThemeSwitcher />
</Paper>
<Center mt={80} mb="lg">
<Stack>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all
your active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Stack>
</Center>
</Container>
</>
);
};

View File

@@ -0,0 +1,200 @@
import {
ActionIcon,
Box,
Button,
Center,
Group,
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 CenterLoader from "../../components/core/CenterLoader";
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 <CenterLoader />;
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

@@ -1,9 +1,11 @@
import { Space, Title } from "@mantine/core";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Meta from "../../components/Meta";
const AdminConfig = () => {
return (
<>
<Meta title="Configuration" />
<Title mb={30} order={3}>
Configuration
</Title>

View File

@@ -11,6 +11,7 @@ import {
import Link from "next/link";
import { useEffect, useState } from "react";
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
import Meta from "../../components/Meta";
import configService from "../../services/config.service";
const useStyles = createStyles((theme) => ({
@@ -62,6 +63,7 @@ const Admin = () => {
return (
<>
<Meta title="Administration" />
<Title mb={30} order={3}>
Administration
</Title>

View File

@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Logo from "../../components/Logo";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
@@ -14,13 +15,14 @@ const Setup = () => {
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("SETUP_FINISHED")) {
} else if (config.get("SETUP_STATUS") == "FINISHED") {
router.push("/");
return;
}
return (
<>
<Meta title="Setup" />
<Stack align="center">
<Logo height={80} width={80} />
<Title order={2}>Welcome to Pingvin Share</Title>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb";
import ManageUserTable from "../../components/admin/ManageUserTable";
import showCreateUserModal from "../../components/admin/showCreateUserModal";
import Meta from "../../components/Meta";
import userService from "../../services/user.service";
import User from "../../types/user.type";
import toast from "../../utils/toast.util";
@@ -47,6 +48,7 @@ const Users = () => {
return (
<>
<Meta title="User management" />
<Group position="apart" align="baseline" mb={20}>
<Title mb={30} order={3}>
User management

View File

@@ -2,13 +2,13 @@ import { Box, Group, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next";
import { useEffect, useState } from "react";
import Meta from "../../components/Meta";
import DownloadAllButton from "../../components/share/DownloadAllButton";
import FileList from "../../components/share/FileList";
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
import showErrorModal from "../../components/share/showErrorModal";
import shareService from "../../services/share.service";
import { Share as ShareType } from "../../types/share.type";
import Meta from "../../../components/Meta";
import DownloadAllButton from "../../../components/share/DownloadAllButton";
import FileList from "../../../components/share/FileList";
import showEnterPasswordModal from "../../../components/share/showEnterPasswordModal";
import showErrorModal from "../../../components/share/showErrorModal";
import shareService from "../../../services/share.service";
import { Share as ShareType } from "../../../types/share.type";
export function getServerSideProps(context: GetServerSidePropsContext) {
return {
@@ -85,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => {
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
</Group>
<FileList files={share?.files} shareId={shareId} isLoading={!share} />
<FileList files={share?.files} share={share!} isLoading={!share} />
</>
);
};

View File

@@ -0,0 +1,92 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { GetServerSidePropsContext } from "next";
import { useState } from "react";
export function getServerSideProps(context: GetServerSidePropsContext) {
const { shareId, fileId } = context.params!;
const mimeType = context.query.type as string;
return {
props: { shareId, fileId, mimeType },
};
}
const UnSupportedFile = () => {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Title order={3}>Preview not supported</Title>
<Text>
A preview for thise file type is unsupported. Please download the file
to view it.
</Text>
</Stack>
</Center>
);
};
const FilePreview = ({
shareId,
fileId,
mimeType,
}: {
shareId: string;
fileId: string;
mimeType: string;
}) => {
const [isNotSupported, setIsNotSupported] = useState(false);
if (isNotSupported) return <UnSupportedFile />;
if (mimeType == "application/pdf") {
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
return null;
} else if (mimeType.startsWith("video/")) {
return (
<video
width="100%"
controls
onError={() => {
setIsNotSupported(true);
}}
>
<source src={`/api/shares/${shareId}/files/${fileId}?download=false`} />
</video>
);
} else if (mimeType.startsWith("image/")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
onError={() => {
setIsNotSupported(true);
}}
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
alt={`${fileId}_preview`}
width="100%"
/>
);
} else if (mimeType.startsWith("audio/")) {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<audio
controls
style={{ width: "100%" }}
onError={() => {
setIsNotSupported(true);
}}
>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
/>
</audio>
</Stack>
</Center>
);
} else {
return <UnSupportedFile />;
}
};
export default FilePreview;

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 +0,0 @@
// TODO: Add user account
const Account = () => {
return <div></div>;
};
export default Account;

View File

@@ -1,6 +1,10 @@
import { setCookie } from "cookies-next";
import mime from "mime-types";
import { FileUploadResponse } from "../types/File.type";
import {
CreateShare,
MyReverseShare,
MyShare,
Share,
ShareMetaData,
@@ -25,21 +29,11 @@ const completeShare = async (id: string) => {
};
const get = async (id: string): Promise<Share> => {
const shareToken = sessionStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data;
return (await api.get(`shares/${id}`)).data;
};
const getMetaData = async (id: string): Promise<ShareMetaData> => {
const shareToken = sessionStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}/metaData`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data;
return (await api.get(`shares/${id}/metaData`)).data;
};
const remove = async (id: string) => {
@@ -51,26 +45,30 @@ const getMyShares = async (): Promise<MyShare[]> => {
};
const getShareToken = async (id: string, password?: string) => {
const { token } = (await api.post(`/shares/${id}/token`, { password })).data;
sessionStorage.setItem(`share_${id}_token`, token);
await api.post(`/shares/${id}/token`, { password });
};
const isShareIdAvailable = async (id: string): Promise<boolean> => {
return (await api.get(`shares/isShareIdAvailable/${id}`)).data.isAvailable;
return (await api.get(`/shares/isShareIdAvailable/${id}`)).data.isAvailable;
};
const getFileDownloadUrl = async (shareId: string, fileId: string) => {
const shareToken = sessionStorage.getItem(`share_${shareId}_token`);
return (
await api.get(`shares/${shareId}/files/${fileId}/download`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data.url;
const doesFileSupportPreview = (fileName: string) => {
const mimeType = mime.contentType(fileName);
if (!mimeType) return false;
const supportedMimeTypes = [
mimeType.startsWith("video/"),
mimeType.startsWith("image/"),
mimeType.startsWith("audio/"),
mimeType == "application/pdf",
];
return supportedMimeTypes.some((isSupported) => isSupported);
};
const downloadFile = async (shareId: string, fileId: string) => {
window.location.href = await getFileDownloadUrl(shareId, fileId);
window.location.href = `${window.location.origin}/api/shares/${shareId}/files/${fileId}`;
};
const uploadFile = async (
@@ -98,6 +96,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,
@@ -105,8 +131,13 @@ export default {
get,
remove,
getMetaData,
doesFileSupportPreview,
getMyShares,
isShareIdAvailable,
downloadFile,
uploadFile,
setReverseShare,
createReverseShare,
getMyReverseShares,
removeReverseShare,
};

View File

@@ -1,3 +1,9 @@
export type FileUpload = File & { uploadingProgress: number };
export type FileUploadResponse = { id: string; name: string };
export type FileMetaData = {
id: string;
name: string;
size: string;
};

View File

@@ -6,6 +6,7 @@ export type Share = {
creator: User;
description?: string;
expiration: Date;
hasPassword: boolean;
};
export type CreateShare = {
@@ -26,6 +27,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]
);
}

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share",
"version": "0.7.0",
"version": "0.9.0",
"scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",