Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5c919110 | ||
|
|
008df06b5c | ||
|
|
cd9d828686 | ||
|
|
233c26e5cf | ||
|
|
91a6b3f716 | ||
|
|
0a2b7b1243 | ||
|
|
b98fe7911f | ||
|
|
ad92cfc852 | ||
|
|
7e91038a24 | ||
|
|
4a5fb549c6 | ||
|
|
1ceb07b89e | ||
|
|
bb64f6c33f | ||
|
|
61c48d57b8 | ||
|
|
2a7587ed78 |
2
.github/workflows/backend-system-tests.yml
vendored
2
.github/workflows/backend-system-tests.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:18
|
container: node:18
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|||||||
6
.github/workflows/build-docker-image.yml
vendored
6
.github/workflows/build-docker-image.yml
vendored
@@ -9,11 +9,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: login to docker registry
|
- name: login to docker registry
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
- name: Build the image
|
- name: Build the image
|
||||||
|
|||||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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)
|
## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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"
|
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).
|
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|||||||
34
backend/package-lock.json
generated
34
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"newman": "^5.3.2",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^2.8.2",
|
"prettier": "^2.8.2",
|
||||||
"prisma": "^4.8.1",
|
"prisma": "^4.9.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.2",
|
"ts-loader": "^9.4.2",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.1.2",
|
||||||
@@ -1010,9 +1010,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "4.8.1",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz",
|
||||||
"integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==",
|
"integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true
|
"hasInstallScript": true
|
||||||
},
|
},
|
||||||
@@ -5871,13 +5871,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "4.8.1",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz",
|
||||||
"integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==",
|
"integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "4.8.1"
|
"@prisma/engines": "4.9.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"prisma": "build/index.js",
|
"prisma": "build/index.js",
|
||||||
@@ -8255,9 +8255,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@prisma/engines": {
|
"@prisma/engines": {
|
||||||
"version": "4.8.1",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz",
|
||||||
"integrity": "sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==",
|
"integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"@prisma/engines-version": {
|
"@prisma/engines-version": {
|
||||||
@@ -11995,12 +11995,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"version": "4.8.1",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz",
|
||||||
"integrity": "sha512-ZMLnSjwulIeYfaU1O6/LF6PEJzxN5par5weykxMykS9Z6ara/j76JH3Yo2AH3bgJbPN4Z6NeCK9s5fDkzf33cg==",
|
"integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@prisma/engines": "4.8.1"
|
"@prisma/engines": "4.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"process-nextick-args": {
|
"process-nextick-args": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"newman": "^5.3.2",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^2.8.2",
|
"prettier": "^2.8.2",
|
||||||
"prisma": "^4.8.1",
|
"prisma": "^4.9.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.2",
|
"ts-loader": "^9.4.2",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.1.2",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -20,6 +20,7 @@ model User {
|
|||||||
shares Share[]
|
shares Share[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
loginTokens LoginToken[]
|
loginTokens LoginToken[]
|
||||||
|
reverseShares ReverseShare[]
|
||||||
|
|
||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
totpVerified Boolean @default(false)
|
totpVerified Boolean @default(false)
|
||||||
@@ -59,13 +60,33 @@ model Share {
|
|||||||
description String?
|
description String?
|
||||||
removedReason String?
|
removedReason String?
|
||||||
|
|
||||||
creatorId String?
|
creatorId String?
|
||||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
reverseShare ReverseShare?
|
||||||
|
|
||||||
security ShareSecurity?
|
security ShareSecurity?
|
||||||
recipients ShareRecipient[]
|
recipients ShareRecipient[]
|
||||||
files File[]
|
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 {
|
model ShareRecipient {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String
|
email String
|
||||||
@@ -107,4 +128,5 @@ model Config {
|
|||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
|
order Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,55 +3,17 @@ import * as crypto from "crypto";
|
|||||||
|
|
||||||
const configVariables: Prisma.ConfigCreateInput[] = [
|
const configVariables: Prisma.ConfigCreateInput[] = [
|
||||||
{
|
{
|
||||||
key: "SETUP_FINISHED",
|
order: 0,
|
||||||
description: "Whether the setup has been finished",
|
key: "SETUP_STATUS",
|
||||||
type: "boolean",
|
description: "Status of the setup wizard",
|
||||||
value: "false",
|
type: "string",
|
||||||
|
value: "STARTED", // STARTED, REGISTERED, FINISHED
|
||||||
category: "internal",
|
category: "internal",
|
||||||
secret: false,
|
secret: false,
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "APP_URL",
|
order: 0,
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "JWT_SECRET",
|
key: "JWT_SECRET",
|
||||||
description: "Long random string used to sign JWT tokens",
|
description: "Long random string used to sign JWT tokens",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -60,6 +22,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
order: 0,
|
||||||
key: "TOTP_SECRET",
|
key: "TOTP_SECRET",
|
||||||
description: "A 16 byte random string used to generate TOTP secrets",
|
description: "A 16 byte random string used to generate TOTP secrets",
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -68,65 +31,150 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
locked: true,
|
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:
|
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",
|
type: "boolean",
|
||||||
value: "false",
|
value: "false",
|
||||||
category: "email",
|
category: "email",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "EMAIL_MESSAGE",
|
order: 7,
|
||||||
|
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
||||||
description:
|
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",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
"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",
|
order: 8,
|
||||||
description: "Subject of the email which gets sent to the recipients.",
|
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent to the share recipients.",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Files shared with you",
|
value: "Files shared with you",
|
||||||
category: "email",
|
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",
|
key: "SMTP_HOST",
|
||||||
description: "Host of the SMTP server",
|
description: "Host of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
category: "email",
|
category: "smtp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
order: 13,
|
||||||
key: "SMTP_PORT",
|
key: "SMTP_PORT",
|
||||||
description: "Port of the SMTP server",
|
description: "Port of the SMTP server",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "0",
|
value: "0",
|
||||||
category: "email",
|
category: "smtp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
order: 14,
|
||||||
key: "SMTP_EMAIL",
|
key: "SMTP_EMAIL",
|
||||||
description: "Email address which the emails get sent from",
|
description: "Email address which the emails get sent from",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
category: "email",
|
category: "smtp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
order: 15,
|
||||||
key: "SMTP_USERNAME",
|
key: "SMTP_USERNAME",
|
||||||
description: "Username of the SMTP server",
|
description: "Username of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
category: "email",
|
category: "smtp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
order: 16,
|
||||||
key: "SMTP_PASSWORD",
|
key: "SMTP_PASSWORD",
|
||||||
description: "Password of the SMTP server",
|
description: "Password of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
category: "email",
|
category: "smtp",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { JobsModule } from "./jobs/jobs.module";
|
|||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { ShareModule } from "./share/share.module";
|
import { ShareModule } from "./share/share.module";
|
||||||
import { UserModule } from "./user/user.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -29,7 +30,8 @@ import { ClamscanModule } from "./clamscan/clamscan.module";
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ClamscanModule,
|
ClamScanModule,
|
||||||
|
ReverseShareModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
|
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
|
||||||
|
|
||||||
const hash = await argon.hash(dto.password);
|
const hash = await argon.hash(dto.password);
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
@@ -30,10 +32,14 @@ export class AuthService {
|
|||||||
email: dto.email,
|
email: dto.email,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password: hash,
|
password: hash,
|
||||||
isAdmin: !this.config.get("SETUP_FINISHED"),
|
isAdmin: isFirstUser,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isFirstUser) {
|
||||||
|
await this.config.changeSetupStatus("REGISTERED");
|
||||||
|
}
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ import { ClamScanService } from "./clamscan.service";
|
|||||||
providers: [ClamScanService],
|
providers: [ClamScanService],
|
||||||
exports: [ClamScanService],
|
exports: [ClamScanService],
|
||||||
})
|
})
|
||||||
export class ClamscanModule {}
|
export class ClamScanModule {}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class ConfigController {
|
|||||||
@Post("admin/finishSetup")
|
@Post("admin/finishSetup")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async finishSetup() {
|
async finishSetup() {
|
||||||
return await this.configService.finishSetup();
|
return await this.configService.changeSetupStatus("FINISHED");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("admin/testEmail")
|
@Post("admin/testEmail")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
async listForAdmin() {
|
async listForAdmin() {
|
||||||
return await this.prisma.config.findMany({
|
return await this.prisma.config.findMany({
|
||||||
|
orderBy: { order: "asc" },
|
||||||
where: { locked: { equals: false } },
|
where: { locked: { equals: false } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,10 +76,10 @@ export class ConfigService {
|
|||||||
return updatedVariable;
|
return updatedVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
async finishSetup() {
|
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
|
||||||
return await this.prisma.config.update({
|
return await this.prisma.config.update({
|
||||||
where: { key: "SETUP_FINISHED" },
|
where: { key: "SETUP_STATUS" },
|
||||||
data: { value: "true" },
|
data: { value: status },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export class EmailService {
|
|||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
getTransporter() {
|
getTransporter() {
|
||||||
|
if (!this.config.get("SMTP_ENABLED"))
|
||||||
|
throw new InternalServerErrorException("SMTP is disabled");
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: this.config.get("SMTP_HOST"),
|
host: this.config.get("SMTP_HOST"),
|
||||||
port: parseInt(this.config.get("SMTP_PORT")),
|
port: parseInt(this.config.get("SMTP_PORT")),
|
||||||
@@ -19,8 +22,12 @@ export class EmailService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
async sendMailToShareRecepients(
|
||||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
recipientEmail: string,
|
||||||
|
shareId: string,
|
||||||
|
creator?: User
|
||||||
|
) {
|
||||||
|
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||||
@@ -28,21 +35,40 @@ export class EmailService {
|
|||||||
await this.getTransporter().sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: this.config.get("EMAIL_SUBJECT"),
|
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
|
||||||
text: this.config
|
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("\\n", "\n")
|
||||||
.replaceAll("{creator}", creator.username)
|
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendTestMail(recipientEmail: string) {
|
||||||
await this.getTransporter().sendMail({
|
try {
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
await this.getTransporter().sendMail({
|
||||||
to: recipientEmail,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
subject: "Test email",
|
to: recipientEmail,
|
||||||
text: "This is a test email",
|
subject: "Test email",
|
||||||
});
|
text: "This is a test email",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new InternalServerErrorException(e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ import {
|
|||||||
import { SkipThrottle } from "@nestjs/throttler";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import * as contentDisposition from "content-disposition";
|
import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
|
||||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||||
|
|
||||||
@Controller("shares/:shareId/files")
|
@Controller("shares/:shareId/files")
|
||||||
export class FileController {
|
export class FileController {
|
||||||
@@ -24,7 +23,7 @@ export class FileController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async create(
|
async create(
|
||||||
@Query() query: any,
|
@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")
|
@Get("zip")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
@@ -74,25 +51,32 @@ export class FileController {
|
|||||||
const zip = this.fileService.getZip(shareId);
|
const zip = this.fileService.getZip(shareId);
|
||||||
res.set({
|
res.set({
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(zip);
|
return new StreamableFile(zip);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId")
|
@Get(":fileId")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string
|
@Param("fileId") fileId: string,
|
||||||
|
@Query("download") download = "true"
|
||||||
) {
|
) {
|
||||||
const file = await this.fileService.get(shareId, fileId);
|
const file = await this.fileService.get(shareId, fileId);
|
||||||
res.set({
|
|
||||||
|
const headers = {
|
||||||
"Content-Type": file.metaData.mimeType,
|
"Content-Type": file.metaData.mimeType,
|
||||||
"Content-Length": file.metaData.size,
|
"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);
|
return new StreamableFile(file.file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||||
import { ShareModule } from "src/share/share.module";
|
import { ShareModule } from "src/share/share.module";
|
||||||
import { FileController } from "./file.controller";
|
import { FileController } from "./file.controller";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), ShareModule],
|
imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
|
||||||
controllers: [FileController],
|
controllers: [FileController],
|
||||||
providers: [FileService],
|
providers: [FileService],
|
||||||
exports: [FileService],
|
exports: [FileService],
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class FileService {
|
|||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { files: true },
|
include: { files: true, reverseShare: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (share.uploadLocked)
|
if (share.uploadLocked)
|
||||||
@@ -64,9 +64,12 @@ export class FileService {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
fileSizeSum + diskFileSize + buffer.byteLength >
|
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
|
||||||
this.config.get("MAX_SHARE_SIZE")
|
(share.reverseShare?.maxShareSize &&
|
||||||
|
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Max share size exceeded",
|
"Max share size exceeded",
|
||||||
@@ -132,38 +135,4 @@ export class FileService {
|
|||||||
getZip(shareId: string) {
|
getZip(shareId: string) {
|
||||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
backend/src/file/guard/fileSecurity.guard.ts
Normal file
65
backend/src/file/guard/fileSecurity.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
12
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsBoolean, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateReverseShareDTO {
|
||||||
|
@IsBoolean()
|
||||||
|
sendEmailNotification: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
maxShareSize: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
shareExpiration: string;
|
||||||
|
}
|
||||||
18
backend/src/reverseShare/dto/reverseShare.dto.ts
Normal file
18
backend/src/reverseShare/dto/reverseShare.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
export class ReverseShareDTO {
|
||||||
|
@Expose()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
maxShareSize: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
shareExpiration: Date;
|
||||||
|
|
||||||
|
from(partial: Partial<ReverseShareDTO>) {
|
||||||
|
return plainToClass(ReverseShareDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/reverseShare/dto/reverseShareTokenWithShare.ts
Normal file
26
backend/src/reverseShare/dto/reverseShareTokenWithShare.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/reverseShare/guards/reverseShareOwner.guard.ts
Normal file
22
backend/src/reverseShare/guards/reverseShareOwner.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReverseShareOwnerGuard implements CanActivate {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
|
const { reverseShareId } = request.params;
|
||||||
|
|
||||||
|
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||||
|
where: { id: reverseShareId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reverseShare) return false;
|
||||||
|
|
||||||
|
return reverseShare.creatorId == (request.user as User).id;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
backend/src/reverseShare/reverseShare.controller.ts
Normal file
64
backend/src/reverseShare/reverseShare.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||||
|
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
||||||
|
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare";
|
||||||
|
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
||||||
|
import { ReverseShareService } from "./reverseShare.service";
|
||||||
|
|
||||||
|
@Controller("reverseShares")
|
||||||
|
export class ReverseShareController {
|
||||||
|
constructor(
|
||||||
|
private reverseShareService: ReverseShareService,
|
||||||
|
private config: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
||||||
|
const token = await this.reverseShareService.create(body, user.id);
|
||||||
|
|
||||||
|
const link = `${this.config.get("APP_URL")}/upload/${token}`;
|
||||||
|
|
||||||
|
return { token, link };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle(20, 60)
|
||||||
|
@Get(":reverseShareToken")
|
||||||
|
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||||
|
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||||
|
|
||||||
|
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||||
|
|
||||||
|
return new ReverseShareDTO().from(
|
||||||
|
await this.reverseShareService.getByToken(reverseShareToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async getAllByUser(@GetUser() user: User) {
|
||||||
|
return new ReverseShareTokenWithShare().fromList(
|
||||||
|
await this.reverseShareService.getAllByUser(user.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":reverseShareId")
|
||||||
|
@UseGuards(JwtGuard, ReverseShareOwnerGuard)
|
||||||
|
async remove(@Param("reverseShareId") id: string) {
|
||||||
|
await this.reverseShareService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/reverseShare/reverseShare.module.ts
Normal file
12
backend/src/reverseShare/reverseShare.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ReverseShareController } from "./reverseShare.controller";
|
||||||
|
import { ReverseShareService } from "./reverseShare.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => FileModule)],
|
||||||
|
controllers: [ReverseShareController],
|
||||||
|
providers: [ReverseShareService],
|
||||||
|
exports: [ReverseShareService],
|
||||||
|
})
|
||||||
|
export class ReverseShareModule {}
|
||||||
94
backend/src/reverseShare/reverseShare.service.ts
Normal file
94
backend/src/reverseShare/reverseShare.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import * as moment from "moment";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { FileService } from "src/file/file.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReverseShareService {
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private fileService: FileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||||
|
// Parse date string to date
|
||||||
|
const expirationDate = moment()
|
||||||
|
.add(
|
||||||
|
data.shareExpiration.split("-")[0],
|
||||||
|
data.shareExpiration.split(
|
||||||
|
"-"
|
||||||
|
)[1] as moment.unitOfTime.DurationConstructor
|
||||||
|
)
|
||||||
|
.toDate();
|
||||||
|
|
||||||
|
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
|
||||||
|
|
||||||
|
if (globalMaxShareSize < data.maxShareSize)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const reverseShare = await this.prisma.reverseShare.create({
|
||||||
|
data: {
|
||||||
|
shareExpiration: expirationDate,
|
||||||
|
maxShareSize: data.maxShareSize,
|
||||||
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
|
creatorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reverseShare.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByToken(reverseShareToken: string) {
|
||||||
|
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||||
|
where: { token: reverseShareToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reverseShare;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllByUser(userId: string) {
|
||||||
|
const reverseShares = await this.prisma.reverseShare.findMany({
|
||||||
|
where: {
|
||||||
|
creatorId: userId,
|
||||||
|
shareExpiration: { gt: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
shareExpiration: "desc",
|
||||||
|
},
|
||||||
|
include: { share: { include: { creator: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reverseShares;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isValid(reverseShareToken: string) {
|
||||||
|
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||||
|
where: { token: reverseShareToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reverseShare) return false;
|
||||||
|
|
||||||
|
const isExpired = new Date() > reverseShare.shareExpiration;
|
||||||
|
const isUsed = reverseShare.used;
|
||||||
|
|
||||||
|
return !(isExpired || isUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const share = await this.prisma.share.findFirst({
|
||||||
|
where: { reverseShare: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (share) {
|
||||||
|
await this.prisma.share.delete({ where: { id: share.id } });
|
||||||
|
await this.fileService.deleteAllFiles(share.id);
|
||||||
|
} else {
|
||||||
|
await this.prisma.reverseShare.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
hasPassword: boolean;
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|||||||
29
backend/src/share/guard/createShare.guard.ts
Normal file
29
backend/src/share/guard/createShare.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareToken = request.get("X-Share-Token");
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId"
|
||||||
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|
||||||
|
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true },
|
include: { security: true },
|
||||||
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
if (
|
if (
|
||||||
!share ||
|
!share ||
|
||||||
(moment().isAfter(share.expiration) &&
|
(moment().isAfter(share.expiration) &&
|
||||||
moment(share.expiration).unix() !== 0)
|
!moment(share.expiration).isSame(0))
|
||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
|
|||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { Request, Response } from "express";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
@@ -17,6 +20,7 @@ import { MyShareDTO } from "./dto/myShare.dto";
|
|||||||
import { ShareDTO } from "./dto/share.dto";
|
import { ShareDTO } from "./dto/share.dto";
|
||||||
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
|
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
|
||||||
import { SharePasswordDto } from "./dto/sharePassword.dto";
|
import { SharePasswordDto } from "./dto/sharePassword.dto";
|
||||||
|
import { CreateShareGuard } from "./guard/createShare.guard";
|
||||||
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||||
@@ -46,9 +50,16 @@ export class ShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(CreateShareGuard)
|
||||||
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
|
async create(
|
||||||
return new ShareDTO().from(await this.shareService.create(body, user));
|
@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")
|
@Delete(":id")
|
||||||
@@ -59,21 +70,35 @@ export class ShareController {
|
|||||||
|
|
||||||
@Post(":id/complete")
|
@Post(":id/complete")
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async complete(@Param("id") id: string) {
|
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||||
return new ShareDTO().from(await this.shareService.complete(id));
|
const { reverse_share_token } = request.cookies;
|
||||||
|
return new ShareDTO().from(
|
||||||
|
await this.shareService.complete(id, reverse_share_token)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throttle(10, 60)
|
||||||
@Get("isShareIdAvailable/:id")
|
@Get("isShareIdAvailable/:id")
|
||||||
async isShareIdAvailable(@Param("id") id: string) {
|
async isShareIdAvailable(@Param("id") id: string) {
|
||||||
return this.shareService.isShareIdAvailable(id);
|
return this.shareService.isShareIdAvailable(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(20, 5 * 60)
|
||||||
@UseGuards(ShareTokenSecurity)
|
@UseGuards(ShareTokenSecurity)
|
||||||
@Post(":id/token")
|
@Post(":id/token")
|
||||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
async getShareToken(
|
||||||
return this.shareService.getShareToken(id, body.password);
|
@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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
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 { EmailModule } from "src/email/email.module";
|
||||||
import { FileModule } from "src/file/file.module";
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||||
import { ShareController } from "./share.controller";
|
import { ShareController } from "./share.controller";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
|
|
||||||
@@ -10,7 +11,8 @@ import { ShareService } from "./share.service";
|
|||||||
imports: [
|
imports: [
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
ClamscanModule,
|
ClamScanModule,
|
||||||
|
ReverseShareModule,
|
||||||
forwardRef(() => FileModule),
|
forwardRef(() => FileModule),
|
||||||
],
|
],
|
||||||
controllers: [ShareController],
|
controllers: [ShareController],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ConfigService } from "src/config/config.service";
|
|||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -25,10 +26,11 @@ export class ShareService {
|
|||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private jwtService: JwtService,
|
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)
|
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
|
||||||
throw new BadRequestException("Share id already in use");
|
throw new BadRequestException("Share id already in use");
|
||||||
|
|
||||||
@@ -39,30 +41,36 @@ export class ShareService {
|
|||||||
share.security.password = await argon.hash(share.security.password);
|
share.security.password = await argon.hash(share.security.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have to add an exception for "never" (since moment won't like that)
|
|
||||||
let expirationDate: Date;
|
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 share is created by a reverse share token override the expiration date
|
||||||
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
|
if (reverseShareToken) {
|
||||||
throw new BadRequestException("Invalid expiration date");
|
const { shareExpiration } = await this.reverseShareService.getByToken(
|
||||||
|
reverseShareToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expirationDate = shareExpiration;
|
||||||
} else {
|
} 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}`, {
|
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.prisma.share.create({
|
const shareTuple = await this.prisma.share.create({
|
||||||
data: {
|
data: {
|
||||||
...share,
|
...share,
|
||||||
expiration: expirationDate,
|
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) {
|
async createZip(shareId: string) {
|
||||||
@@ -96,10 +116,15 @@ export class ShareService {
|
|||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(id: string) {
|
async complete(id: string, reverseShareToken?: string) {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
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))
|
if (await this.isShareCompleted(id))
|
||||||
@@ -118,15 +143,33 @@ export class ShareService {
|
|||||||
|
|
||||||
// Send email for each recepient
|
// Send email for each recepient
|
||||||
for (const recepient of share.recipients) {
|
for (const recepient of share.recipients) {
|
||||||
await this.emailService.sendMail(
|
await this.emailService.sendMailToShareRecepients(
|
||||||
recepient.email,
|
recepient.email,
|
||||||
share.id,
|
share.id,
|
||||||
share.creator
|
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
|
// 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({
|
return await this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -161,12 +204,13 @@ export class ShareService {
|
|||||||
return sharesWithEmailRecipients;
|
return sharesWithEmailRecipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string): Promise<any> {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
|
security: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,8 +219,10 @@ export class ShareService {
|
|||||||
|
|
||||||
if (!share || !share.uploadLocked)
|
if (!share || !share.uploadLocked)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
return {
|
||||||
return share as any;
|
...share,
|
||||||
|
hasPassword: share.security?.password ? true : false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaData(id: string) {
|
async getMetaData(id: string) {
|
||||||
@@ -230,12 +276,20 @@ export class ShareService {
|
|||||||
if (
|
if (
|
||||||
share?.security?.password &&
|
share?.security?.password &&
|
||||||
!(await argon.verify(share.security.password, password))
|
!(await argon.verify(share.security.password, password))
|
||||||
)
|
) {
|
||||||
throw new ForbiddenException("Wrong 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);
|
const token = await this.generateShareToken(shareId);
|
||||||
await this.increaseViewCount(share);
|
await this.increaseViewCount(share);
|
||||||
return { token };
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateShareToken(shareId: string) {
|
async generateShareToken(shareId: string) {
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/opt/app/backend/data"
|
- "./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)
|
# Optional: Add ClamAV (see README.md)
|
||||||
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||||
# clamav:
|
# clamav:
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
# image: clamav/clamav
|
# image: clamav/clamav
|
||||||
|
|||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.2",
|
"jose": "^4.11.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
@@ -2656,6 +2658,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"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": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"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": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.2",
|
"jose": "^4.11.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const AdminConfigTable = () => {
|
|||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||||
|
|
||||||
let updatedConfigVariables: UpdateConfig[] = [];
|
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
|
||||||
|
UpdateConfig[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||||
const index = updatedConfigVariables.findIndex(
|
const index = updatedConfigVariables.findIndex(
|
||||||
@@ -38,7 +40,7 @@ const AdminConfigTable = () => {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
updatedConfigVariables[index] = configVariable;
|
updatedConfigVariables[index] = configVariable;
|
||||||
} else {
|
} 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(() => {
|
useEffect(() => {
|
||||||
getConfigVariables();
|
getConfigVariables();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -100,9 +122,12 @@ const AdminConfigTable = () => {
|
|||||||
<Space h="lg" />
|
<Space h="lg" />
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
{category == "email" && (
|
{category == "smtp" && (
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<TestEmailButton />
|
<TestEmailButton
|
||||||
|
configVariablesChanged={updatedConfigVariables.length != 0}
|
||||||
|
saveConfigVariables={saveConfigVariables}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -110,29 +135,7 @@ const AdminConfigTable = () => {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<Button
|
<Button onClick={saveConfigVariables}>Save</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>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 useUser from "../../../hooks/user.hook";
|
||||||
import configService from "../../../services/config.service";
|
import configService from "../../../services/config.service";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const TestEmailButton = () => {
|
const TestEmailButton = ({
|
||||||
|
configVariablesChanged,
|
||||||
|
saveConfigVariables,
|
||||||
|
}: {
|
||||||
|
configVariablesChanged: boolean;
|
||||||
|
saveConfigVariables: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
const { user } = useUser();
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
configService
|
if (!configVariablesChanged) {
|
||||||
.sendTestEmail(user!.email)
|
setIsLoading(true);
|
||||||
.then(() => toast.success("Email sent successfully"))
|
await sendTestEmail();
|
||||||
.catch(() =>
|
setIsLoading(false);
|
||||||
toast.error(
|
} else {
|
||||||
"Failed to send the email. Please check the backend logs for more information."
|
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
|
Send test email
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
13
frontend/src/components/core/CenterLoader.tsx
Normal file
13
frontend/src/components/core/CenterLoader.tsx
Normal 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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
||||||
import Link from "next/link";
|
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 useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
|
|
||||||
@@ -11,17 +11,10 @@ const ActionAvatar = () => {
|
|||||||
<Menu position="bottom-start" withinPortal>
|
<Menu position="bottom-start" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon>
|
<ActionIcon>
|
||||||
<Avatar size={28} radius="xl" />
|
<Avatar size={28} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<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} />}>
|
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||||
My account
|
My account
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Burger,
|
Burger,
|
||||||
Container,
|
Container,
|
||||||
@@ -13,10 +14,12 @@ import {
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import { TbPlus } from "react-icons/tb";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
import ActionAvatar from "./ActionAvatar";
|
import ActionAvatar from "./ActionAvatar";
|
||||||
|
import NavbarShareMenu from "./NavbarShareMenu";
|
||||||
|
|
||||||
const HEADER_HEIGHT = 60;
|
const HEADER_HEIGHT = 60;
|
||||||
|
|
||||||
@@ -117,6 +120,9 @@ const NavBar = () => {
|
|||||||
link: "/upload",
|
link: "/upload",
|
||||||
label: "Upload",
|
label: "Upload",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: <NavbarShareMenu />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
component: <ActionAvatar />,
|
component: <ActionAvatar />,
|
||||||
},
|
},
|
||||||
|
|||||||
29
frontend/src/components/navBar/NavbarShareMenu.tsx
Normal file
29
frontend/src/components/navBar/NavbarShareMenu.tsx
Normal 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;
|
||||||
@@ -1,18 +1,57 @@
|
|||||||
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
|
import {
|
||||||
import { TbCircleCheck, TbDownload } from "react-icons/tb";
|
ActionIcon,
|
||||||
import shareService from "../../services/share.service";
|
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 = ({
|
const FileList = ({
|
||||||
files,
|
files,
|
||||||
shareId,
|
share,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
files?: any[];
|
files?: FileMetaData[];
|
||||||
shareId: string;
|
share: Share;
|
||||||
isLoading: boolean;
|
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 (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -28,24 +67,35 @@ const FileList = ({
|
|||||||
: files!.map((file) => (
|
: files!.map((file) => (
|
||||||
<tr key={file.name}>
|
<tr key={file.name}>
|
||||||
<td>{file.name}</td>
|
<td>{file.name}</td>
|
||||||
<td>{byteStringToHumanSizeString(file.size)}</td>
|
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||||
<td>
|
<td>
|
||||||
{file.uploadingState ? (
|
<Group position="right">
|
||||||
file.uploadingState != "finished" ? (
|
{shareService.doesFileSupportPreview(file.name) && (
|
||||||
<Loader size={22} />
|
<ActionIcon
|
||||||
) : (
|
component={Link}
|
||||||
<TbCircleCheck color="green" size={22} />
|
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
|
<ActionIcon
|
||||||
size={25}
|
size={25}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await shareService.downloadFile(shareId, file.id);
|
await shareService.downloadFile(share.id, file.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TbDownload />
|
<TbDownload />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
</Group>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
62
frontend/src/components/share/FileSizeInput.tsx
Normal file
62
frontend/src/components/share/FileSizeInput.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -4,7 +4,7 @@ import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
|||||||
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import { FileUpload } from "../../types/File.type";
|
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";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
@@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
|
|||||||
|
|
||||||
const Dropzone = ({
|
const Dropzone = ({
|
||||||
isUploading,
|
isUploading,
|
||||||
|
maxShareSize,
|
||||||
files,
|
files,
|
||||||
setFiles,
|
setFiles,
|
||||||
}: {
|
}: {
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
|
maxShareSize: number;
|
||||||
files: FileUpload[];
|
files: FileUpload[];
|
||||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -58,10 +60,10 @@ const Dropzone = ({
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fileSizeSum > config.get("MAX_SHARE_SIZE")) {
|
if (fileSizeSum > maxShareSize) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Your files exceed the maximum share size of ${byteStringToHumanSizeString(
|
`Your files exceed the maximum share size of ${byteToHumanSizeString(
|
||||||
config.get("MAX_SHARE_SIZE")
|
maxShareSize
|
||||||
)}.`
|
)}.`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -84,9 +86,8 @@ const Dropzone = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||||
Drag'n'drop files here to start your share. We can accept
|
Drag'n'drop files here to start your share. We can accept
|
||||||
only files that are less than{" "}
|
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
|
||||||
{byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in
|
in total.
|
||||||
total.
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</MantineDropzone>
|
</MantineDropzone>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -2,7 +2,7 @@ import { ActionIcon, Table } from "@mantine/core";
|
|||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
import { TbTrash } from "react-icons/tb";
|
import { TbTrash } from "react-icons/tb";
|
||||||
import { FileUpload } from "../../types/File.type";
|
import { FileUpload } from "../../types/File.type";
|
||||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||||
|
|
||||||
const FileList = ({
|
const FileList = ({
|
||||||
@@ -19,7 +19,7 @@ const FileList = ({
|
|||||||
const rows = files.map((file, i) => (
|
const rows = files.map((file, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td>{file.name}</td>
|
<td>{file.name}</td>
|
||||||
<td>{byteStringToHumanSizeString(file.size.toString())}</td>
|
<td>{byteToHumanSizeString(file.size)}</td>
|
||||||
<td>
|
<td>
|
||||||
{file.uploadingProgress == 0 ? (
|
{file.uploadingProgress == 0 ? (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
@@ -24,12 +23,13 @@ import { TbAlertCircle } from "react-icons/tb";
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import shareService from "../../../services/share.service";
|
import shareService from "../../../services/share.service";
|
||||||
import { CreateShare } from "../../../types/share.type";
|
import { CreateShare } from "../../../types/share.type";
|
||||||
import ExpirationPreview from "../ExpirationPreview";
|
import { getExpirationPreview } from "../../../utils/date.util";
|
||||||
|
|
||||||
const showCreateUploadModal = (
|
const showCreateUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
options: {
|
options: {
|
||||||
isUserSignedIn: boolean;
|
isUserSignedIn: boolean;
|
||||||
|
isReverseShare: boolean;
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
@@ -54,6 +54,7 @@ const CreateUploadModalBody = ({
|
|||||||
uploadCallback: (createShare: CreateShare) => void;
|
uploadCallback: (createShare: CreateShare) => void;
|
||||||
options: {
|
options: {
|
||||||
isUserSignedIn: boolean;
|
isUserSignedIn: boolean;
|
||||||
|
isReverseShare: boolean;
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
@@ -89,7 +90,7 @@ const CreateUploadModalBody = ({
|
|||||||
validate: yupResolver(validationSchema),
|
validate: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Group>
|
<>
|
||||||
{showNotSignedInAlert && !options.isUserSignedIn && (
|
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||||
<Alert
|
<Alert
|
||||||
withCloseButton
|
withCloseButton
|
||||||
@@ -161,72 +162,78 @@ const CreateUploadModalBody = ({
|
|||||||
{options.appUrl}/share/
|
{options.appUrl}/share/
|
||||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||||
</Text>
|
</Text>
|
||||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
{!options.isReverseShare && (
|
||||||
<Col xs={6}>
|
<>
|
||||||
<NumberInput
|
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||||
min={1}
|
<Col xs={6}>
|
||||||
max={99999}
|
<NumberInput
|
||||||
precision={0}
|
min={1}
|
||||||
variant="filled"
|
max={99999}
|
||||||
label="Expiration"
|
precision={0}
|
||||||
placeholder="n"
|
variant="filled"
|
||||||
disabled={form.values.never_expires}
|
label="Expiration"
|
||||||
{...form.getInputProps("expiration_num")}
|
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>
|
<Text
|
||||||
<Col xs={6}>
|
italic
|
||||||
<Select
|
size="xs"
|
||||||
disabled={form.values.never_expires}
|
sx={(theme) => ({
|
||||||
{...form.getInputProps("expiration_unit")}
|
color: theme.colors.gray[6],
|
||||||
data={[
|
})}
|
||||||
// Set the label to singular if the number is 1, else plural
|
>
|
||||||
{
|
{getExpirationPreview("share", form)}
|
||||||
value: "-minutes",
|
</Text>
|
||||||
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>
|
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>Description</Accordion.Control>
|
<Accordion.Control>Description</Accordion.Control>
|
||||||
@@ -296,7 +303,7 @@ const CreateUploadModalBody = ({
|
|||||||
<Button type="submit">Share</Button>
|
<Button type="submit">Share</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Group>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,24 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
getInitalData();
|
getInitalData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Redirect to setup page if setup is not completed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
configVariables &&
|
configVariables &&
|
||||||
configVariables.filter((variable) => variable.key)[0].value == "false" &&
|
|
||||||
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
|
!["/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(() => {
|
useEffect(() => {
|
||||||
setColorScheme(
|
setColorScheme(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Tb2Fa } from "react-icons/tb";
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||||
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
||||||
|
import Meta from "../../components/Meta";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../services/user.service";
|
||||||
@@ -90,186 +91,192 @@ const Account = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="sm">
|
<>
|
||||||
<Title order={3} mb="xs">
|
<Meta title="My account" />
|
||||||
My account
|
<Container size="sm">
|
||||||
</Title>
|
<Title order={3} mb="xs">
|
||||||
<Paper withBorder p="xl">
|
My account
|
||||||
<Title order={5} mb="xs">
|
|
||||||
Account Info
|
|
||||||
</Title>
|
</Title>
|
||||||
<form
|
<Paper withBorder p="xl">
|
||||||
onSubmit={accountForm.onSubmit((values) =>
|
<Title order={5} mb="xs">
|
||||||
userService
|
Account Info
|
||||||
.updateCurrentUser({
|
</Title>
|
||||||
username: values.username,
|
<form
|
||||||
email: values.email,
|
onSubmit={accountForm.onSubmit((values) =>
|
||||||
})
|
userService
|
||||||
.then(() => toast.success("User updated successfully"))
|
.updateCurrentUser({
|
||||||
.catch(toast.axiosError)
|
username: values.username,
|
||||||
)}
|
email: values.email,
|
||||||
>
|
})
|
||||||
<Stack>
|
.then(() => toast.success("User updated successfully"))
|
||||||
<TextInput
|
.catch(toast.axiosError)
|
||||||
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
|
<Stack>
|
||||||
</Button>
|
<TextInput
|
||||||
</Stack>
|
label="Username"
|
||||||
</Center>
|
{...accountForm.getInputProps("username")}
|
||||||
</Container>
|
/>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
200
frontend/src/pages/account/reverseShares.tsx
Normal file
200
frontend/src/pages/account/reverseShares.tsx
Normal 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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
@@ -61,83 +62,85 @@ const MyShares = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||||
<thead>
|
<Table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Name</th>
|
<tr>
|
||||||
<th>Visitors</th>
|
<th>Name</th>
|
||||||
<th>Expires at</th>
|
<th>Visitors</th>
|
||||||
<th></th>
|
<th>Expires at</th>
|
||||||
</tr>
|
<th></th>
|
||||||
</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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</Table>
|
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Space, Title } from "@mantine/core";
|
import { Space, Title } from "@mantine/core";
|
||||||
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||||
|
import Meta from "../../components/Meta";
|
||||||
|
|
||||||
const AdminConfig = () => {
|
const AdminConfig = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Meta title="Configuration" />
|
||||||
<Title mb={30} order={3}>
|
<Title mb={30} order={3}>
|
||||||
Configuration
|
Configuration
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
|
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
|
||||||
|
import Meta from "../../components/Meta";
|
||||||
import configService from "../../services/config.service";
|
import configService from "../../services/config.service";
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
@@ -62,6 +63,7 @@ const Admin = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Meta title="Administration" />
|
||||||
<Title mb={30} order={3}>
|
<Title mb={30} order={3}>
|
||||||
Administration
|
Administration
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||||
|
|
||||||
import Logo from "../../components/Logo";
|
import Logo from "../../components/Logo";
|
||||||
|
import Meta from "../../components/Meta";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
|
|
||||||
@@ -14,13 +15,14 @@ const Setup = () => {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
router.push("/auth/signUp");
|
router.push("/auth/signUp");
|
||||||
return;
|
return;
|
||||||
} else if (config.get("SETUP_FINISHED")) {
|
} else if (config.get("SETUP_STATUS") == "FINISHED") {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Meta title="Setup" />
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Logo height={80} width={80} />
|
<Logo height={80} width={80} />
|
||||||
<Title order={2}>Welcome to Pingvin Share</Title>
|
<Title order={2}>Welcome to Pingvin Share</Title>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { TbPlus } from "react-icons/tb";
|
import { TbPlus } from "react-icons/tb";
|
||||||
import ManageUserTable from "../../components/admin/ManageUserTable";
|
import ManageUserTable from "../../components/admin/ManageUserTable";
|
||||||
import showCreateUserModal from "../../components/admin/showCreateUserModal";
|
import showCreateUserModal from "../../components/admin/showCreateUserModal";
|
||||||
|
import Meta from "../../components/Meta";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../services/user.service";
|
||||||
import User from "../../types/user.type";
|
import User from "../../types/user.type";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
@@ -47,6 +48,7 @@ const Users = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Meta title="User management" />
|
||||||
<Group position="apart" align="baseline" mb={20}>
|
<Group position="apart" align="baseline" mb={20}>
|
||||||
<Title mb={30} order={3}>
|
<Title mb={30} order={3}>
|
||||||
User management
|
User management
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Box, Group, Text, Title } from "@mantine/core";
|
|||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../../components/Meta";
|
||||||
import DownloadAllButton from "../../components/share/DownloadAllButton";
|
import DownloadAllButton from "../../../components/share/DownloadAllButton";
|
||||||
import FileList from "../../components/share/FileList";
|
import FileList from "../../../components/share/FileList";
|
||||||
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
|
import showEnterPasswordModal from "../../../components/share/showEnterPasswordModal";
|
||||||
import showErrorModal from "../../components/share/showErrorModal";
|
import showErrorModal from "../../../components/share/showErrorModal";
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../../services/share.service";
|
||||||
import { Share as ShareType } from "../../types/share.type";
|
import { Share as ShareType } from "../../../types/share.type";
|
||||||
|
|
||||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => {
|
|||||||
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
|
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<FileList files={share?.files} shareId={shareId} isLoading={!share} />
|
<FileList files={share?.files} share={share!} isLoading={!share} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
92
frontend/src/pages/share/[shareId]/preview/[fileId].tsx
Normal file
92
frontend/src/pages/share/[shareId]/preview/[fileId].tsx
Normal 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;
|
||||||
43
frontend/src/pages/upload/[reverseShareToken].tsx
Normal file
43
frontend/src/pages/upload/[reverseShareToken].tsx
Normal 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;
|
||||||
@@ -2,27 +2,34 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { cleanNotifications } from "@mantine/notifications";
|
import { cleanNotifications } from "@mantine/notifications";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
|
import { getCookie } from "cookies-next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Meta from "../components/Meta";
|
import Meta from "../../components/Meta";
|
||||||
import Dropzone from "../components/upload/Dropzone";
|
import Dropzone from "../../components/upload/Dropzone";
|
||||||
import FileList from "../components/upload/FileList";
|
import FileList from "../../components/upload/FileList";
|
||||||
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
|
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
|
||||||
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
|
import showCreateUploadModal from "../../components/upload/modals/showCreateUploadModal";
|
||||||
import useConfig from "../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import shareService from "../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
import { FileUpload } from "../types/File.type";
|
import { FileUpload } from "../../types/File.type";
|
||||||
import { CreateShare, Share } from "../types/share.type";
|
import { CreateShare, Share } from "../../types/share.type";
|
||||||
import toast from "../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const promiseLimit = pLimit(3);
|
const promiseLimit = pLimit(3);
|
||||||
const chunkSize = 10 * 1024 * 1024; // 10MB
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
let errorToastShown = false;
|
let errorToastShown = false;
|
||||||
let createdShare: Share;
|
let createdShare: Share;
|
||||||
|
|
||||||
const Upload = () => {
|
const Upload = ({
|
||||||
|
maxShareSize,
|
||||||
|
isReverseShare = false,
|
||||||
|
}: {
|
||||||
|
maxShareSize?: number;
|
||||||
|
isReverseShare: boolean;
|
||||||
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
|
||||||
@@ -31,6 +38,8 @@ const Upload = () => {
|
|||||||
const [files, setFiles] = useState<FileUpload[]>([]);
|
const [files, setFiles] = useState<FileUpload[]>([]);
|
||||||
const [isUploading, setisUploading] = useState(false);
|
const [isUploading, setisUploading] = useState(false);
|
||||||
|
|
||||||
|
maxShareSize ??= parseInt(config.get("MAX_SHARE_SIZE"));
|
||||||
|
|
||||||
const uploadFiles = async (share: CreateShare) => {
|
const uploadFiles = async (share: CreateShare) => {
|
||||||
setisUploading(true);
|
setisUploading(true);
|
||||||
createdShare = await shareService.create(share);
|
createdShare = await shareService.create(share);
|
||||||
@@ -138,9 +147,9 @@ const Upload = () => {
|
|||||||
) {
|
) {
|
||||||
shareService
|
shareService
|
||||||
.completeShare(createdShare.id)
|
.completeShare(createdShare.id)
|
||||||
.then(() => {
|
.then((share) => {
|
||||||
setisUploading(false);
|
setisUploading(false);
|
||||||
showCompletedUploadModal(modals, createdShare, config.get("APP_URL"));
|
showCompletedUploadModal(modals, share, config.get("APP_URL"));
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
@@ -149,8 +158,13 @@ const Upload = () => {
|
|||||||
}
|
}
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
if (
|
||||||
|
!user &&
|
||||||
|
!config.get("ALLOW_UNAUTHENTICATED_SHARES") &&
|
||||||
|
!getCookie("reverse_share_token")
|
||||||
|
) {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -164,11 +178,14 @@ const Upload = () => {
|
|||||||
modals,
|
modals,
|
||||||
{
|
{
|
||||||
isUserSignedIn: user ? true : false,
|
isUserSignedIn: user ? true : false,
|
||||||
|
isReverseShare,
|
||||||
appUrl: config.get("APP_URL"),
|
appUrl: config.get("APP_URL"),
|
||||||
allowUnauthenticatedShares: config.get(
|
allowUnauthenticatedShares: config.get(
|
||||||
"ALLOW_UNAUTHENTICATED_SHARES"
|
"ALLOW_UNAUTHENTICATED_SHARES"
|
||||||
),
|
),
|
||||||
enableEmailRecepients: config.get("ENABLE_EMAIL_RECIPIENTS"),
|
enableEmailRecepients: config.get(
|
||||||
|
"ENABLE_SHARE_EMAIL_RECIPIENTS"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
uploadFiles
|
uploadFiles
|
||||||
);
|
);
|
||||||
@@ -177,7 +194,12 @@ const Upload = () => {
|
|||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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} />}
|
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// TODO: Add user account
|
|
||||||
const Account = () => {
|
|
||||||
return <div></div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Account;
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { setCookie } from "cookies-next";
|
||||||
|
import mime from "mime-types";
|
||||||
import { FileUploadResponse } from "../types/File.type";
|
import { FileUploadResponse } from "../types/File.type";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateShare,
|
CreateShare,
|
||||||
|
MyReverseShare,
|
||||||
MyShare,
|
MyShare,
|
||||||
Share,
|
Share,
|
||||||
ShareMetaData,
|
ShareMetaData,
|
||||||
@@ -25,21 +29,11 @@ const completeShare = async (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const get = async (id: string): Promise<Share> => {
|
const get = async (id: string): Promise<Share> => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${id}_token`);
|
return (await api.get(`shares/${id}`)).data;
|
||||||
return (
|
|
||||||
await api.get(`shares/${id}`, {
|
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${id}_token`);
|
return (await api.get(`shares/${id}/metaData`)).data;
|
||||||
return (
|
|
||||||
await api.get(`shares/${id}/metaData`, {
|
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -51,26 +45,30 @@ const getMyShares = async (): Promise<MyShare[]> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getShareToken = async (id: string, password?: string) => {
|
const getShareToken = async (id: string, password?: string) => {
|
||||||
const { token } = (await api.post(`/shares/${id}/token`, { password })).data;
|
await api.post(`/shares/${id}/token`, { password });
|
||||||
|
|
||||||
sessionStorage.setItem(`share_${id}_token`, token);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isShareIdAvailable = async (id: string): Promise<boolean> => {
|
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 doesFileSupportPreview = (fileName: string) => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${shareId}_token`);
|
const mimeType = mime.contentType(fileName);
|
||||||
return (
|
|
||||||
await api.get(`shares/${shareId}/files/${fileId}/download`, {
|
if (!mimeType) return false;
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
const supportedMimeTypes = [
|
||||||
).data.url;
|
mimeType.startsWith("video/"),
|
||||||
|
mimeType.startsWith("image/"),
|
||||||
|
mimeType.startsWith("audio/"),
|
||||||
|
mimeType == "application/pdf",
|
||||||
|
];
|
||||||
|
|
||||||
|
return supportedMimeTypes.some((isSupported) => isSupported);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async (shareId: string, fileId: string) => {
|
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 (
|
const uploadFile = async (
|
||||||
@@ -98,6 +96,34 @@ const uploadFile = async (
|
|||||||
).data;
|
).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 {
|
export default {
|
||||||
create,
|
create,
|
||||||
completeShare,
|
completeShare,
|
||||||
@@ -105,8 +131,13 @@ export default {
|
|||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
getMetaData,
|
getMetaData,
|
||||||
|
doesFileSupportPreview,
|
||||||
getMyShares,
|
getMyShares,
|
||||||
isShareIdAvailable,
|
isShareIdAvailable,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
setReverseShare,
|
||||||
|
createReverseShare,
|
||||||
|
getMyReverseShares,
|
||||||
|
removeReverseShare,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export type FileUpload = File & { uploadingProgress: number };
|
export type FileUpload = File & { uploadingProgress: number };
|
||||||
|
|
||||||
export type FileUploadResponse = { id: string; name: string };
|
export type FileUploadResponse = { id: string; name: string };
|
||||||
|
|
||||||
|
export type FileMetaData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type Share = {
|
|||||||
creator: User;
|
creator: User;
|
||||||
description?: string;
|
description?: string;
|
||||||
expiration: Date;
|
expiration: Date;
|
||||||
|
hasPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateShare = {
|
export type CreateShare = {
|
||||||
@@ -26,6 +27,13 @@ export type MyShare = Share & {
|
|||||||
cratedAt: Date;
|
cratedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MyReverseShare = {
|
||||||
|
id: string;
|
||||||
|
maxShareSize: string;
|
||||||
|
shareExpiration: Date;
|
||||||
|
share?: MyShare;
|
||||||
|
};
|
||||||
|
|
||||||
export type ShareSecurity = {
|
export type ShareSecurity = {
|
||||||
maxViews?: number;
|
maxViews?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|||||||
26
frontend/src/utils/date.util.ts
Normal file
26
frontend/src/utils/date.util.ts
Normal 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")}`;
|
||||||
|
};
|
||||||
23
frontend/src/utils/fileSize.util.ts
Normal file
23
frontend/src/utils/fileSize.util.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
||||||
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
||||||
|
|||||||
Reference in New Issue
Block a user