Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6ef52a17 | ||
|
|
b9662701c4 | ||
|
|
e3f88d0826 | ||
|
|
86a7379519 | ||
|
|
ccdf8ea3ae | ||
|
|
edc10b72b7 | ||
|
|
5d1a7f0310 | ||
|
|
8ab359b71d | ||
|
|
38de022215 | ||
|
|
82f204e8a9 | ||
|
|
4e840ecd29 | ||
|
|
064ef38d78 | ||
|
|
b14e931d8d | ||
|
|
3d5c919110 | ||
|
|
008df06b5c | ||
|
|
cd9d828686 | ||
|
|
233c26e5cf | ||
|
|
91a6b3f716 | ||
|
|
0a2b7b1243 | ||
|
|
b98fe7911f | ||
|
|
ad92cfc852 | ||
|
|
7e91038a24 | ||
|
|
4a5fb549c6 | ||
|
|
1ceb07b89e | ||
|
|
bb64f6c33f | ||
|
|
61c48d57b8 | ||
|
|
2a7587ed78 | ||
|
|
e09213a295 | ||
|
|
fc116d65c0 | ||
|
|
76088cc76a | ||
|
|
16b697053a | ||
|
|
349bf475cc |
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
|
||||
container: node:18
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Dependencies
|
||||
working-directory: ./backend
|
||||
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
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: login to docker registry
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
|
||||
1
.github/workflows/close_inactive_issues.yml
vendored
1
.github/workflows/close_inactive_issues.yml
vendored
@@ -14,6 +14,7 @@ jobs:
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
exempt-issue-labels: "feature"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,3 +1,63 @@
|
||||
## [0.10.0](https://github.com/stonith404/pingvin-share/compare/v0.9.0...v0.10.0) (2023-02-10)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* reset password with email
|
||||
|
||||
### Features
|
||||
|
||||
* allow multiple shares with one reverse share link ([ccdf8ea](https://github.com/stonith404/pingvin-share/commit/ccdf8ea3ae1e7b8520c5b1dd9bea18b1b3305f35))
|
||||
* **frontend:** server side rendering to improve performance ([38de022](https://github.com/stonith404/pingvin-share/commit/38de022215a9b99c2eb36654f8dbb1e17ca87aba))
|
||||
* reset password with email ([5d1a7f0](https://github.com/stonith404/pingvin-share/commit/5d1a7f0310df2643213affd2a0d1785b7e0af398))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete all shares of reverse share ([86a7379](https://github.com/stonith404/pingvin-share/commit/86a737951951c911abd7967d76cb253c4335cb0c))
|
||||
* invalid redirection after jwt expiry ([82f204e](https://github.com/stonith404/pingvin-share/commit/82f204e8a93e3113dcf65b1881d4943a898602eb))
|
||||
* setup status doesn't change ([064ef38](https://github.com/stonith404/pingvin-share/commit/064ef38d783b3f351535c2911eb451efd9526d71))
|
||||
* share creation without reverseShareToken ([b966270](https://github.com/stonith404/pingvin-share/commit/b9662701c42fe6771c07acb869564031accb2932))
|
||||
* share fails if a share was created with a reverse share link recently ([edc10b7](https://github.com/stonith404/pingvin-share/commit/edc10b72b7884c629a8417c3c82222b135ef7653))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ClamAV to scan for malicious files ([76088cc](https://github.com/stonith404/pingvin-share/commit/76088cc76aedae709f06deaee2244efcf6a22bed))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid github release link on admin page ([349bf47](https://github.com/stonith404/pingvin-share/commit/349bf475cc7fc1141dbd2a9bd2f63153c4d5b41b))
|
||||
|
||||
### [0.6.1](https://github.com/stonith404/pingvin-share/compare/v0.6.0...v0.6.1) (2023-01-11)
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:18-slim AS runner
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=docker
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
|
||||
15
README.md
15
README.md
@@ -4,13 +4,12 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Spin up your instance within 2 minutes
|
||||
- Create a share with files that you can access with a link
|
||||
- No file size limit, only your disk will be your limit
|
||||
- Set a share expiration
|
||||
- Optionally secure your share with a visitor limit and a password
|
||||
- Email recepients
|
||||
- Light & dark mode
|
||||
- ClamAV integration
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
|
||||
@@ -30,6 +29,18 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
|
||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Integrations
|
||||
|
||||
#### ClamAV
|
||||
|
||||
With ClamAV the shares get scanned for malicious files and get removed if any found.
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||
|
||||
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"]
|
||||
}
|
||||
}
|
||||
|
||||
1420
backend/package-lock.json
generated
1420
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.10.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
@@ -16,60 +16,62 @@
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^9.0.0",
|
||||
"@nestjs/mapped-types": "^1.2.0",
|
||||
"@nestjs/jwt": "^10.0.1",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/swagger": "^6.2.1",
|
||||
"@nestjs/throttler": "^3.1.0",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"@prisma/client": "^4.8.1",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.2",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.1",
|
||||
"clamscan": "^2.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nodemailer": "^6.8.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.6.0",
|
||||
"rimraf": "^4.0.4",
|
||||
"rxjs": "^7.8.0",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.5",
|
||||
"@nestjs/schematics": "^9.0.3",
|
||||
"@nestjs/cli": "^9.1.8",
|
||||
"@nestjs/schematics": "^9.0.4",
|
||||
"@nestjs/testing": "^9.2.1",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/clamscan": "^2.0.4",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^18.11.10",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.8.0",
|
||||
"prisma": "^4.7.1",
|
||||
"prettier": "^2.8.2",
|
||||
"prisma": "^4.9.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.3",
|
||||
"wait-on": "^6.0.1"
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "^4.9.4",
|
||||
"wait-on": "^7.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
|
||||
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "ResetPasswordToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Disable TOTP as secret isn't encrypted anymore
|
||||
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
|
||||
|
||||
-- RedefineTables
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"description" TEXT,
|
||||
"removedReason" TEXT,
|
||||
"creatorId" TEXT,
|
||||
"reverseShareId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
|
||||
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
|
||||
FROM "Share";
|
||||
|
||||
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
CREATE TABLE "new_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,
|
||||
"remainingUses" INTEGER NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare";
|
||||
DROP TABLE "ReverseShare";
|
||||
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");
|
||||
@@ -20,10 +20,12 @@ model User {
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -48,23 +50,54 @@ model LoginToken {
|
||||
used Boolean @default(false)
|
||||
}
|
||||
|
||||
model ResetPasswordToken {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
uploadLocked Boolean @default(false)
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
expiration DateTime
|
||||
description String?
|
||||
uploadLocked Boolean @default(false)
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
expiration DateTime
|
||||
description String?
|
||||
removedReason String?
|
||||
|
||||
creatorId String?
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
reverseShareId String?
|
||||
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
|
||||
|
||||
creatorId String?
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
}
|
||||
|
||||
model ReverseShare {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
token String @unique @default(uuid())
|
||||
shareExpiration DateTime
|
||||
maxShareSize String
|
||||
sendEmailNotification Boolean
|
||||
remainingUses Int
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
shares Share[]
|
||||
}
|
||||
|
||||
model ShareRecipient {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
@@ -106,4 +139,5 @@ model Config {
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
}
|
||||
|
||||
@@ -3,55 +3,17 @@ import * as crypto from "crypto";
|
||||
|
||||
const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
{
|
||||
key: "SETUP_FINISHED",
|
||||
description: "Whether the setup has been finished",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
order: 0,
|
||||
key: "SETUP_STATUS",
|
||||
description: "Status of the setup wizard",
|
||||
type: "string",
|
||||
value: "STARTED", // STARTED, REGISTERED, FINISHED
|
||||
category: "internal",
|
||||
secret: false,
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "APP_URL",
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: "http://localhost:3000",
|
||||
category: "general",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "SHOW_HOME_PAGE",
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
category: "general",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_REGISTRATION",
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "MAX_SHARE_SIZE",
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
value: "1073741824",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 0,
|
||||
key: "JWT_SECRET",
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
@@ -60,73 +22,170 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "TOTP_SECRET",
|
||||
description: "A 16 byte random string used to generate TOTP secrets",
|
||||
order: 1,
|
||||
key: "APP_URL",
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(16).toString("base64"),
|
||||
category: "internal",
|
||||
locked: true,
|
||||
value: "http://localhost:3000",
|
||||
category: "general",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ENABLE_EMAIL_RECIPIENTS",
|
||||
order: 2,
|
||||
key: "SHOW_HOME_PAGE",
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
category: "general",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
key: "ALLOW_REGISTRATION",
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 5,
|
||||
|
||||
key: "MAX_SHARE_SIZE",
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
value: "1073741824",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
|
||||
{
|
||||
order: 6,
|
||||
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
|
||||
description:
|
||||
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
category: "email",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "EMAIL_MESSAGE",
|
||||
order: 7,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
key: "EMAIL_SUBJECT",
|
||||
description: "Subject of the email which gets sent to the recipients.",
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
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: 10,
|
||||
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: 11,
|
||||
key: "RESET_PASSWORD_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
value: "Pingvin Share password reset",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 12,
|
||||
key: "RESET_PASSWORD_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
||||
category: "email",
|
||||
},
|
||||
|
||||
{
|
||||
order: 13,
|
||||
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: 14,
|
||||
key: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "email",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 15,
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "0",
|
||||
category: "email",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 16,
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "email",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 17,
|
||||
key: "SMTP_USERNAME",
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "email",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 18,
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
obscured: true,
|
||||
category: "email",
|
||||
category: "smtp",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { JobsModule } from "./jobs/jobs.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +30,8 @@ import { UserModule } from "./user/user.module";
|
||||
limit: 100,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||
import { TokenDTO } from "./dto/token.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||
@@ -34,14 +36,15 @@ export class AuthController {
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
@Throttle(10, 5 * 60)
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
|
||||
const result = await this.authService.signUp(dto);
|
||||
|
||||
response = this.addTokensToResponse(
|
||||
@@ -53,8 +56,8 @@ export class AuthController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
@@ -73,8 +76,8 @@ export class AuthController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn/totp")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@@ -91,6 +94,20 @@ export class AuthController {
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
return await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(
|
||||
@@ -119,7 +136,7 @@ export class AuthController {
|
||||
const accessToken = await this.authService.refreshAccessToken(
|
||||
request.cookies.refresh_token
|
||||
);
|
||||
response.cookie("access_token", accessToken);
|
||||
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||
return new TokenDTO().from({ accessToken });
|
||||
}
|
||||
|
||||
@@ -161,11 +178,13 @@ export class AuthController {
|
||||
refreshToken?: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
if (accessToken) response.cookie("access_token", accessToken);
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||
if (refreshToken)
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
imports: [JwtModule.register({}), EmailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
@@ -19,10 +20,13 @@ export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
|
||||
|
||||
const hash = await argon.hash(dto.password);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
@@ -30,10 +34,14 @@ export class AuthService {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: !this.config.get("SETUP_FINISHED"),
|
||||
isAdmin: isFirstUser,
|
||||
},
|
||||
});
|
||||
|
||||
if (isFirstUser) {
|
||||
await this.config.changeSetupStatus("REGISTERED");
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
);
|
||||
@@ -81,6 +89,50 @@ export class AuthService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async requestResetPassword(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
include: { resetPasswordToken: true },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token: user.resetPasswordToken.token },
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await this.prisma.resetPasswordToken.create({
|
||||
data: {
|
||||
expiresAt: moment().add(1, "hour").toDate(),
|
||||
user: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { resetPasswordToken: { token } },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||
|
||||
const newPasswordHash = await argon.hash(newPassword);
|
||||
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newPasswordHash },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
@@ -104,6 +156,7 @@ export class AuthService {
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
refreshTokenId,
|
||||
},
|
||||
{
|
||||
@@ -114,16 +167,19 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signOut(accessToken: string) {
|
||||
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
||||
refreshTokenId: string;
|
||||
};
|
||||
const { refreshTokenId } =
|
||||
(this.jwtService.decode(accessToken) as {
|
||||
refreshTokenId: string;
|
||||
}) || {};
|
||||
|
||||
await this.prisma.refreshToken
|
||||
.delete({ where: { id: refreshTokenId } })
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
if (refreshTokenId) {
|
||||
await this.prisma.refreshToken
|
||||
.delete({ where: { id: refreshTokenId } })
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string) {
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@Injectable()
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
@@ -57,9 +54,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (dto.totp !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
@@ -81,41 +76,6 @@ export class AuthTotpService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
encryptTotpSecret(totpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
|
||||
let encrypted = cipher.update(totpSecret);
|
||||
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
@@ -132,7 +92,6 @@ export class AuthTotpService {
|
||||
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const secret = authenticator.generateSecret();
|
||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
@@ -144,7 +103,7 @@ export class AuthTotpService {
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpEnabled: true,
|
||||
totpSecret: encryptedSecret,
|
||||
totpSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,9 +136,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not in progress");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
@@ -208,9 +165,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInTotpDTO extends PickType(UserDTO, [
|
||||
"password",
|
||||
] as const) {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
import { IsString } from "class-validator";
|
||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||
|
||||
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||
@IsString()
|
||||
totp: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||
|
||||
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
|
||||
10
backend/src/clamscan/clamscan.module.ts
Normal file
10
backend/src/clamscan/clamscan.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ClamScanService } from "./clamscan.service";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => FileModule)],
|
||||
providers: [ClamScanService],
|
||||
exports: [ClamScanService],
|
||||
})
|
||||
export class ClamScanModule {}
|
||||
86
backend/src/clamscan/clamscan.service.ts
Normal file
86
backend/src/clamscan/clamscan.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as NodeClam from "clamscan";
|
||||
import * as fs from "fs";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
const clamscanConfig = {
|
||||
clamdscan: {
|
||||
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
||||
port: 3310,
|
||||
localFallback: false,
|
||||
},
|
||||
preference: "clamdscan",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ClamScanService {
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
.init(clamscanConfig)
|
||||
.then((res) => {
|
||||
console.log("ClamAV is active");
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
return null;
|
||||
});
|
||||
|
||||
async check(shareId: string) {
|
||||
const clamScan = await this.ClamScan;
|
||||
|
||||
if (!clamScan) return [];
|
||||
|
||||
const infectedFiles = [];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(`./data/uploads/shares/${shareId}`)
|
||||
.filter((file) => file != "archive.zip");
|
||||
|
||||
for (const fileId of files) {
|
||||
const { isInfected } = await clamScan
|
||||
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
return { isInfected: false };
|
||||
});
|
||||
|
||||
const fileName = (
|
||||
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||
).name;
|
||||
|
||||
if (isInfected) {
|
||||
infectedFiles.push({ id: fileId, name: fileName });
|
||||
}
|
||||
}
|
||||
|
||||
return infectedFiles;
|
||||
}
|
||||
|
||||
async checkAndRemove(shareId: string) {
|
||||
const infectedFiles = await this.check(shareId);
|
||||
|
||||
if (infectedFiles.length > 0) {
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
await this.prisma.file.deleteMany({ where: { shareId } });
|
||||
|
||||
const fileNames = infectedFiles.map((file) => file.name).join(", ");
|
||||
|
||||
await this.prisma.share.update({
|
||||
where: { id: shareId },
|
||||
data: {
|
||||
removedReason: `Your share got removed because the file(s) ${fileNames} are malicious.`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { SkipThrottle } from "@nestjs/throttler";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
@@ -16,6 +17,7 @@ export class ConfigController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@SkipThrottle()
|
||||
async list() {
|
||||
return new ConfigDTO().fromList(await this.configService.list());
|
||||
}
|
||||
@@ -37,7 +39,7 @@ export class ConfigController {
|
||||
@Post("admin/finishSetup")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async finishSetup() {
|
||||
return await this.configService.finishSetup();
|
||||
return await this.configService.changeSetupStatus("FINISHED");
|
||||
}
|
||||
|
||||
@Post("admin/testEmail")
|
||||
|
||||
@@ -29,6 +29,7 @@ export class ConfigService {
|
||||
|
||||
async listForAdmin() {
|
||||
return await this.prisma.config.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
where: { locked: { equals: false } },
|
||||
});
|
||||
}
|
||||
@@ -75,10 +76,14 @@ export class ConfigService {
|
||||
return updatedVariable;
|
||||
}
|
||||
|
||||
async finishSetup() {
|
||||
return await this.prisma.config.update({
|
||||
where: { key: "SETUP_FINISHED" },
|
||||
data: { value: "true" },
|
||||
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: { key: "SETUP_STATUS" },
|
||||
data: { value: status },
|
||||
});
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getTransporter() {
|
||||
if (!this.config.get("SMTP_ENABLED"))
|
||||
throw new InternalServerErrorException("SMTP is disabled");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: this.config.get("SMTP_HOST"),
|
||||
port: parseInt(this.config.get("SMTP_PORT")),
|
||||
@@ -19,8 +22,12 @@ export class EmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||
async sendMailToShareRecepients(
|
||||
recipientEmail: string,
|
||||
shareId: string,
|
||||
creator?: User
|
||||
) {
|
||||
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS"))
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
|
||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||
@@ -28,21 +35,55 @@ export class EmailService {
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("EMAIL_SUBJECT"),
|
||||
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("EMAIL_MESSAGE")
|
||||
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator.username)
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
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: "Test email",
|
||||
text: "This is a test email",
|
||||
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("REVERSE_SHARE_EMAIL_MESSAGE")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("RESET_PASSWORD_EMAIL_MESSAGE")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
text: "This is a test email",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,10 @@ import {
|
||||
import { SkipThrottle } from "@nestjs/throttler";
|
||||
import * as contentDisposition from "content-disposition";
|
||||
import { Response } from "express";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
||||
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
@@ -24,7 +23,7 @@ export class FileController {
|
||||
|
||||
@Post()
|
||||
@SkipThrottle()
|
||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async create(
|
||||
@Query() query: any,
|
||||
|
||||
@@ -43,30 +42,8 @@ export class FileController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":fileId/download")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getFileDownloadUrl(
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
) {
|
||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Get("zip/download")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getZipArchiveDownloadURL(
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
) {
|
||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
@Get("zip")
|
||||
@UseGuards(FileDownloadGuard)
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string
|
||||
@@ -74,25 +51,32 @@ export class FileController {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
||||
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
}
|
||||
|
||||
@Get(":fileId")
|
||||
@UseGuards(FileDownloadGuard)
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getFile(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string
|
||||
@Param("fileId") fileId: string,
|
||||
@Query("download") download = "true"
|
||||
) {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
res.set({
|
||||
|
||||
const headers = {
|
||||
"Content-Type": file.metaData.mimeType,
|
||||
"Content-Length": file.metaData.size,
|
||||
"Content-Disposition": contentDisposition(file.metaData.name),
|
||||
});
|
||||
};
|
||||
|
||||
if (download === "true") {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||
}
|
||||
|
||||
res.set(headers);
|
||||
|
||||
return new StreamableFile(file.file);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||
import { ShareModule } from "src/share/share.module";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ShareModule],
|
||||
imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
exports: [FileService],
|
||||
|
||||
@@ -30,7 +30,7 @@ export class FileService {
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { files: true },
|
||||
include: { files: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
@@ -64,9 +64,12 @@ export class FileService {
|
||||
0
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
fileSizeSum + diskFileSize + buffer.byteLength >
|
||||
this.config.get("MAX_SHARE_SIZE")
|
||||
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
@@ -132,38 +135,4 @@ export class FileService {
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
||||
}
|
||||
|
||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
||||
|
||||
return `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
||||
}
|
||||
|
||||
generateFileDownloadToken(shareId: string, fileId: string) {
|
||||
if (fileId == "zip") fileId = undefined;
|
||||
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
shareId,
|
||||
fileId,
|
||||
},
|
||||
{
|
||||
expiresIn: "10min",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
verifyFileDownloadToken(shareId: string, token: string) {
|
||||
try {
|
||||
const claims = this.jwtService.verify(token, {
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
});
|
||||
return claims.shareId == shareId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||
import { JobsService } from "./jobs.service";
|
||||
|
||||
@Module({
|
||||
imports: [FileModule],
|
||||
imports: [FileModule, ReverseShareModule],
|
||||
providers: [JobsService],
|
||||
})
|
||||
export class JobsModule {}
|
||||
|
||||
@@ -4,11 +4,13 @@ import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private fileService: FileService
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,24 @@ export class JobsService {
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredReverseShares() {
|
||||
const expiredReverseShares = await this.prisma.reverseShare.findMany({
|
||||
where: {
|
||||
shareExpiration: { lt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
for (const expiredReverseShare of expiredReverseShares) {
|
||||
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||
}
|
||||
|
||||
if (expiredReverseShares.length > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
);
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
let filesDeleted = 0;
|
||||
@@ -69,14 +89,25 @@ export class JobsService {
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredRefreshTokens() {
|
||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
||||
async deleteExpiredTokens() {
|
||||
const { count: refreshTokenCount } =
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
|
||||
const { count: loginTokenCount } = await this.prisma.loginToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
|
||||
if (expiredRefreshTokens.count > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens`
|
||||
);
|
||||
const { count: resetPasswordTokenCount } =
|
||||
await this.prisma.resetPasswordToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
|
||||
const deletedTokensCount =
|
||||
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||
|
||||
if (deletedTokensCount > 0)
|
||||
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import * as bodyParser from "body-parser";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import * as fs from "fs";
|
||||
@@ -11,13 +12,24 @@ async function bootstrap() {
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
app.use(bodyParser.raw({type:'application/octet-stream', limit:'20mb'}));
|
||||
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||
app.use(cookieParser());
|
||||
app.set("trust proxy", true);
|
||||
|
||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
// Setup Swagger in development mode
|
||||
if (process.env.NODE_ENV == "development") {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("Pingvin Share API")
|
||||
.setVersion("1.0")
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(8080);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
16
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
16
backend/src/reverseShare/dto/createReverseShare.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsBoolean, IsString, Max, Min } from "class-validator";
|
||||
|
||||
export class CreateReverseShareDTO {
|
||||
@IsBoolean()
|
||||
sendEmailNotification: boolean;
|
||||
|
||||
@IsString()
|
||||
maxShareSize: string;
|
||||
|
||||
@IsString()
|
||||
shareExpiration: string;
|
||||
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
maxUseCount: number;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
29
backend/src/reverseShare/dto/reverseShareTokenWithShares.ts
Normal file
29
backend/src/reverseShare/dto/reverseShareTokenWithShares.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
||||
import { ReverseShareDTO } from "./reverseShare.dto";
|
||||
|
||||
export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
"shareExpiration",
|
||||
] as const) {
|
||||
@Expose()
|
||||
shareExpiration: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||
shares: Omit<
|
||||
MyShareDTO,
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||
>[];
|
||||
|
||||
@Expose()
|
||||
remainingUses: number;
|
||||
|
||||
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ReverseShareTokenWithShares, 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 { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
|
||||
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 ReverseShareTokenWithShares().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 {}
|
||||
97
backend/src/reverseShare/reverseShare.service.ts
Normal file
97
backend/src/reverseShare/reverseShare.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
remainingUses: data.maxUseCount,
|
||||
maxShareSize: data.maxShareSize,
|
||||
sendEmailNotification: data.sendEmailNotification,
|
||||
creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return reverseShare.token;
|
||||
}
|
||||
|
||||
async getByToken(reverseShareToken?: string) {
|
||||
if (!reverseShareToken) return null;
|
||||
|
||||
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: { shares: { 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 remainingUsesExceeded = reverseShare.remainingUses <= 0;
|
||||
|
||||
return !(isExpired || remainingUsesExceeded);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const shares = await this.prisma.share.findMany({
|
||||
where: { reverseShare: { id } },
|
||||
});
|
||||
|
||||
for (const share of shares) {
|
||||
await this.prisma.share.delete({ where: { id: share.id } });
|
||||
await this.fileService.deleteAllFiles(share.id);
|
||||
}
|
||||
|
||||
await this.prisma.reverseShare.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class ShareDTO {
|
||||
@Expose()
|
||||
description: string;
|
||||
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
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,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
|
||||
@Injectable()
|
||||
export class ShareSecurityGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private shareService: ShareService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareToken = request.get("X-Share-Token");
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true },
|
||||
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
if (
|
||||
!share ||
|
||||
(moment().isAfter(share.expiration) &&
|
||||
moment(share.expiration).unix() !== 0)
|
||||
!moment(share.expiration).isSame(0))
|
||||
)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
|
||||
)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views)
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
@@ -17,6 +20,7 @@ import { MyShareDTO } from "./dto/myShare.dto";
|
||||
import { ShareDTO } from "./dto/share.dto";
|
||||
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
|
||||
import { SharePasswordDto } from "./dto/sharePassword.dto";
|
||||
import { CreateShareGuard } from "./guard/createShare.guard";
|
||||
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||
@@ -46,9 +50,16 @@ export class ShareController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
|
||||
return new ShareDTO().from(await this.shareService.create(body, user));
|
||||
@UseGuards(CreateShareGuard)
|
||||
async create(
|
||||
@Body() body: CreateShareDTO,
|
||||
@Req() request: Request,
|
||||
@GetUser() user: User
|
||||
) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new ShareDTO().from(
|
||||
await this.shareService.create(body, user, reverse_share_token)
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@@ -59,21 +70,35 @@ export class ShareController {
|
||||
|
||||
@Post(":id/complete")
|
||||
@HttpCode(202)
|
||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||
async complete(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.complete(id));
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new ShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token)
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle(10, 60)
|
||||
@Get("isShareIdAvailable/:id")
|
||||
async isShareIdAvailable(@Param("id") id: string) {
|
||||
return this.shareService.isShareIdAvailable(id);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Throttle(10, 5 * 60)
|
||||
@Throttle(20, 5 * 60)
|
||||
@UseGuards(ShareTokenSecurity)
|
||||
@Post(":id/token")
|
||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
||||
return this.shareService.getShareToken(id, body.password);
|
||||
async getShareToken(
|
||||
@Param("id") id: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: SharePasswordDto
|
||||
) {
|
||||
const token = await this.shareService.getShareToken(id, body.password);
|
||||
response.cookie(`share_${id}_token`, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
return { token };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { ClamScanModule } from "src/clamscan/clamscan.module";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||
import { ShareController } from "./share.controller";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
|
||||
imports: [
|
||||
JwtModule.register({}),
|
||||
EmailModule,
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
forwardRef(() => FileModule),
|
||||
],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
|
||||
@@ -10,10 +10,12 @@ import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { ClamScanService } from "src/clamscan/clamscan.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -23,10 +25,12 @@ export class ShareService {
|
||||
private fileService: FileService,
|
||||
private emailService: EmailService,
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService
|
||||
private jwtService: JwtService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private clamScanService: ClamScanService
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user?: User) {
|
||||
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
||||
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
|
||||
throw new BadRequestException("Share id already in use");
|
||||
|
||||
@@ -37,30 +41,35 @@ export class ShareService {
|
||||
share.security.password = await argon.hash(share.security.password);
|
||||
}
|
||||
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
let expirationDate: Date;
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
// Throw error if expiration date is now
|
||||
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
|
||||
throw new BadRequestException("Invalid expiration date");
|
||||
// If share is created by a reverse share token override the expiration date
|
||||
const reverseShare = await this.reverseShareService.getByToken(
|
||||
reverseShareToken
|
||||
);
|
||||
if (reverseShare) {
|
||||
expirationDate = reverseShare.shareExpiration;
|
||||
} else {
|
||||
expirationDate = moment(0).toDate();
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
} else {
|
||||
expirationDate = moment(0).toDate();
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
return await this.prisma.share.create({
|
||||
const shareTuple = await this.prisma.share.create({
|
||||
data: {
|
||||
...share,
|
||||
expiration: expirationDate,
|
||||
@@ -73,6 +82,20 @@ export class ShareService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (reverseShare) {
|
||||
// Assign share to reverse share token
|
||||
await this.prisma.reverseShare.update({
|
||||
where: { token: reverseShareToken },
|
||||
data: {
|
||||
shares: {
|
||||
connect: { id: shareTuple.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return shareTuple;
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
@@ -94,10 +117,15 @@ export class ShareService {
|
||||
await archive.finalize();
|
||||
}
|
||||
|
||||
async complete(id: string) {
|
||||
async complete(id: string, reverseShareToken?: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: { files: true, recipients: true, creator: true },
|
||||
include: {
|
||||
files: true,
|
||||
recipients: true,
|
||||
creator: true,
|
||||
reverseShare: { include: { creator: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (await this.isShareCompleted(id))
|
||||
@@ -116,13 +144,34 @@ export class ShareService {
|
||||
|
||||
// Send email for each recepient
|
||||
for (const recepient of share.recipients) {
|
||||
await this.emailService.sendMail(
|
||||
await this.emailService.sendMailToShareRecepients(
|
||||
recepient.email,
|
||||
share.id,
|
||||
share.creator
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
share.reverseShare &&
|
||||
this.config.get("SMTP_ENABLED") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
share.reverseShare.creator.email,
|
||||
share.id
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any file is malicious with ClamAV
|
||||
this.clamScanService.checkAndRemove(share.id);
|
||||
|
||||
if (share.reverseShare) {
|
||||
await this.prisma.reverseShare.update({
|
||||
where: { token: reverseShareToken },
|
||||
data: { remainingUses: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return await this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
@@ -156,19 +205,25 @@ export class ShareService {
|
||||
return sharesWithEmailRecipients;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const share: any = await this.prisma.share.findUnique({
|
||||
async get(id: string): Promise<any> {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: true,
|
||||
creator: true,
|
||||
security: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (share.removedReason)
|
||||
throw new NotFoundException(share.removedReason, "share_removed");
|
||||
|
||||
if (!share || !share.uploadLocked)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
return share;
|
||||
return {
|
||||
...share,
|
||||
hasPassword: share.security?.password ? true : false,
|
||||
};
|
||||
}
|
||||
|
||||
async getMetaData(id: string) {
|
||||
@@ -222,12 +277,20 @@ export class ShareService {
|
||||
if (
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password");
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
const token = await this.generateShareToken(shareId);
|
||||
await this.increaseViewCount(share);
|
||||
return { token };
|
||||
return token;
|
||||
}
|
||||
|
||||
async generateShareToken(shareId: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OmitType, PartialType } from "@nestjs/mapped-types";
|
||||
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class UpdateOwnUserDTO extends PartialType(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateUserDTO } from "./createUser.dto";
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as argon from "argon2";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
import { UserDTO } from "./dto/user.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
|
||||
"_postman_id": "cd31bdf9-d558-42da-9231-154721476cd2",
|
||||
"name": "Pingvin Share Testing",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "17822132"
|
||||
@@ -804,16 +804,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||
"host": [
|
||||
@@ -853,16 +843,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||
"host": [
|
||||
@@ -987,7 +967,8 @@
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||
"});",
|
||||
"",
|
||||
"pm.collectionVariables.set(\"shareToken\", pm.response.json().token)"
|
||||
"pm.collectionVariables.set(\"COOKIES\", `${pm.collectionVariables.get(\"COOKIES\")};${pm.response.headers.get(\"Set-Cookie\")}`)",
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
@@ -1041,8 +1022,6 @@
|
||||
" pm.expect(responseBody.files.length).be.equal(2)",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
@@ -1051,13 +1030,7 @@
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-Share-Token",
|
||||
"value": "{{shareToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId",
|
||||
"host": [
|
||||
@@ -1077,88 +1050,6 @@
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get file download url",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"let URL = require('url');",
|
||||
"",
|
||||
"pm.test(\"Status code is 200\", () => {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
"pm.test(\"Response body correct\", () => {",
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
||||
"",
|
||||
"pm.collectionVariables.set(\"fileDownloadPath\",path )"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-Share-Token",
|
||||
"value": "{{shareToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"shares",
|
||||
":shareId",
|
||||
"files",
|
||||
":fileId",
|
||||
"download"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "test-share"
|
||||
},
|
||||
{
|
||||
"key": "fileId",
|
||||
"value": "{{fileId}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get File",
|
||||
"event": [
|
||||
@@ -1174,97 +1065,11 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/{{fileDownloadPath}}",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"{{fileDownloadPath}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get zip download url",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"let URL = require('url');",
|
||||
"",
|
||||
"pm.test(\"Status code is 200\", () => {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
"pm.test(\"Response body correct\", () => {",
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
||||
"",
|
||||
"pm.collectionVariables.set(\"zipDownloadPath\",path )"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-Share-Token",
|
||||
"value": "{{shareToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/{{fileId}}",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
@@ -1272,8 +1077,7 @@
|
||||
"shares",
|
||||
":shareId",
|
||||
"files",
|
||||
"zip",
|
||||
"download"
|
||||
"{{fileId}}"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
@@ -1306,64 +1110,16 @@
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/{{zipDownloadPath}}",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"{{zipDownloadPath}}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Negative",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get share - No token",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 403\", () => {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId",
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/zip",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"shares",
|
||||
":shareId"
|
||||
":shareId",
|
||||
"files",
|
||||
"zip"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
@@ -1374,7 +1130,12 @@
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Negative",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get share token - Wrong password",
|
||||
"event": [
|
||||
@@ -1468,128 +1229,6 @@
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get file download url - No token",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 403\", () => {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"shares",
|
||||
":shareId",
|
||||
"files",
|
||||
":fileId",
|
||||
"download"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "test-share"
|
||||
},
|
||||
{
|
||||
"key": "fileId",
|
||||
"value": "{{fileId}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get zip download url - No token",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 403\", () => {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
},
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"shares",
|
||||
":shareId",
|
||||
"files",
|
||||
"zip",
|
||||
"download"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "test-share"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
7
docker-compose-dev.yml
Normal file
7
docker-compose-dev.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
clamav:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3310:3310
|
||||
image: clamav/clamav
|
||||
@@ -6,4 +6,13 @@ services:
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- "${PWD}/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)
|
||||
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||
# clamav:
|
||||
# restart: unless-stopped
|
||||
# image: clamav/clamav
|
||||
|
||||
748
frontend/package-lock.json
generated
748
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.10.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -11,19 +11,21 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.9.2",
|
||||
"@mantine/dropzone": "^5.9.2",
|
||||
"@mantine/form": "^5.9.2",
|
||||
"@mantine/hooks": "^5.9.2",
|
||||
"@mantine/modals": "^5.9.2",
|
||||
"@mantine/next": "^5.9.2",
|
||||
"@mantine/notifications": "^5.9.2",
|
||||
"axios": "^1.2.0",
|
||||
"@mantine/core": "^5.10.0",
|
||||
"@mantine/dropzone": "^5.10.0",
|
||||
"@mantine/form": "^5.10.0",
|
||||
"@mantine/hooks": "^5.10.0",
|
||||
"@mantine/modals": "^5.10.0",
|
||||
"@mantine/next": "^5.10.0",
|
||||
"@mantine/notifications": "^5.10.0",
|
||||
"axios": "^1.2.2",
|
||||
"cookies-next": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.11.1",
|
||||
"jose": "^4.11.2",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.0.6",
|
||||
"next": "^13.1.2",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
@@ -34,15 +36,16 @@
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.11.10",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"axios": "^1.2.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-next": "^13.0.6",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.8.0",
|
||||
"tar": "^6.1.12",
|
||||
"typescript": "^4.9.3"
|
||||
"@types/react-dom": "18.0.10",
|
||||
"axios": "^1.2.2",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "^13.1.2",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"prettier": "^2.8.2",
|
||||
"tar": "^6.1.13",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,20 @@ const Meta = ({
|
||||
title: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const metaTitle = `${title} - Pingvin Share`;
|
||||
|
||||
return (
|
||||
<Head>
|
||||
{/* TODO: Doesn't work because script get only executed on client side */}
|
||||
<title>{title} - Pingvin Share</title>
|
||||
<meta name="og:title" content={`${title} - Pingvin Share`} />
|
||||
<title>{metaTitle}</title>
|
||||
<meta name="og:title" content={metaTitle} />
|
||||
<meta
|
||||
name="og:description"
|
||||
content={
|
||||
description ?? "An open-source and self-hosted sharing platform."
|
||||
}
|
||||
/>
|
||||
<meta name="twitter:title" content={`${title} - Pingvin Share`} />
|
||||
<meta property="og:image" content="/img/opengraph-default.png" />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
</Head>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ const ThemeSwitcher = () => {
|
||||
);
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const systemColorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
|
||||
const showShareLinkModal = (modals: ModalsContextProps, shareId: string, appUrl : string) => {
|
||||
const showShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
shareId: string,
|
||||
appUrl: string
|
||||
) => {
|
||||
const link = `${appUrl}/share/${shareId}`;
|
||||
return modals.openModal({
|
||||
title: "Share link",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import useConfig from "../../../hooks/config.hook";
|
||||
import configService from "../../../services/config.service";
|
||||
@@ -27,9 +28,18 @@ import TestEmailButton from "./TestEmailButton";
|
||||
|
||||
const AdminConfigTable = () => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||
|
||||
let updatedConfigVariables: UpdateConfig[] = [];
|
||||
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
|
||||
UpdateConfig[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.get("SETUP_STATUS") != "FINISHED") {
|
||||
config.refresh();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||
const index = updatedConfigVariables.findIndex(
|
||||
@@ -38,7 +48,7 @@ const AdminConfigTable = () => {
|
||||
if (index > -1) {
|
||||
updatedConfigVariables[index] = configVariable;
|
||||
} else {
|
||||
updatedConfigVariables.push(configVariable);
|
||||
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,6 +70,27 @@ const AdminConfigTable = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveConfigVariables = async () => {
|
||||
if (config.get("SETUP_STATUS") == "REGISTERED") {
|
||||
await configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(async () => {
|
||||
await configService.finishSetup();
|
||||
router.replace("/upload");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
} else {
|
||||
await configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(() => {
|
||||
setUpdatedConfigVariables([]);
|
||||
toast.success("Configurations updated successfully");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
}
|
||||
config.refresh();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getConfigVariables();
|
||||
}, []);
|
||||
@@ -100,9 +131,12 @@ const AdminConfigTable = () => {
|
||||
<Space h="lg" />
|
||||
</>
|
||||
))}
|
||||
{category == "email" && (
|
||||
{category == "smtp" && (
|
||||
<Group position="right">
|
||||
<TestEmailButton />
|
||||
<TestEmailButton
|
||||
configVariablesChanged={updatedConfigVariables.length != 0}
|
||||
saveConfigVariables={saveConfigVariables}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -110,29 +144,7 @@ const AdminConfigTable = () => {
|
||||
}
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (config.get("SETUP_FINISHED")) {
|
||||
configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(() => {
|
||||
updatedConfigVariables = [];
|
||||
toast.success("Configurations updated successfully");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
} else {
|
||||
configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(async () => {
|
||||
await configService.finishSetup();
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={saveConfigVariables}>Save</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,69 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useState } from "react";
|
||||
import useUser from "../../../hooks/user.hook";
|
||||
import configService from "../../../services/config.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const TestEmailButton = () => {
|
||||
const TestEmailButton = ({
|
||||
configVariablesChanged,
|
||||
saveConfigVariables,
|
||||
}: {
|
||||
configVariablesChanged: boolean;
|
||||
saveConfigVariables: () => Promise<void>;
|
||||
}) => {
|
||||
const { user } = useUser();
|
||||
const modals = useModals();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
await configService
|
||||
.sendTestEmail(user!.email)
|
||||
.then(() => toast.success("Email sent successfully"))
|
||||
.catch((e) =>
|
||||
modals.openModal({
|
||||
title: "Failed to send email",
|
||||
children: (
|
||||
<Stack spacing="xs">
|
||||
<Text size="sm">
|
||||
While sending the test email, the following error occurred:
|
||||
</Text>
|
||||
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||
</Stack>
|
||||
),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
loading={isLoading}
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
configService
|
||||
.sendTestEmail(user!.email)
|
||||
.then(() => toast.success("Email sent successfully"))
|
||||
.catch(() =>
|
||||
toast.error(
|
||||
"Failed to send the email. Please check the backend logs for more information."
|
||||
)
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!configVariablesChanged) {
|
||||
setIsLoading(true);
|
||||
await sendTestEmail();
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
modals.openConfirmModal({
|
||||
title: "Save configuration",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
To continue you need to save the configuration first. Do you
|
||||
want to save the configuration and send the test email?
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Save and send", cancel: "Cancel" },
|
||||
onConfirm: async () => {
|
||||
setIsLoading(true);
|
||||
await saveConfigVariables();
|
||||
await sendTestEmail();
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send test email
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
@@ -11,15 +12,20 @@ import {
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { showNotification } from "@mantine/notifications";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { TbInfoCircle } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const SignInForm = () => {
|
||||
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const [showTotp, setShowTotp] = React.useState(false);
|
||||
const [loginToken, setLoginToken] = React.useState("");
|
||||
|
||||
@@ -42,10 +48,10 @@ const SignInForm = () => {
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
const signIn = async (email: string, password: string) => {
|
||||
await authService
|
||||
.signIn(email, password)
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
if (response.data["loginToken"]) {
|
||||
// Prompt the user to enter their totp code
|
||||
setShowTotp(true);
|
||||
@@ -58,7 +64,8 @@ const SignInForm = () => {
|
||||
});
|
||||
setLoginToken(response.data["loginToken"]);
|
||||
} else {
|
||||
window.location.replace("/");
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
@@ -67,7 +74,10 @@ const SignInForm = () => {
|
||||
const signInTotp = (email: string, password: string, totp: string) => {
|
||||
authService
|
||||
.signInTotp(email, password, totp, loginToken)
|
||||
.then(() => window.location.replace("/"))
|
||||
.then(async () => {
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.response?.data?.message == "Login token expired") {
|
||||
toast.error("Login token expired");
|
||||
@@ -82,13 +92,7 @@ const SignInForm = () => {
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Welcome back
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
@@ -109,7 +113,7 @@ const SignInForm = () => {
|
||||
>
|
||||
<TextInput
|
||||
label="Email or username"
|
||||
placeholder="you@email.com"
|
||||
placeholder="Your email or username"
|
||||
{...form.getInputProps("emailOrUsername")}
|
||||
/>
|
||||
<PasswordInput
|
||||
@@ -127,6 +131,13 @@ const SignInForm = () => {
|
||||
{...form.getInputProps("totp")}
|
||||
/>
|
||||
)}
|
||||
{config.get("SMTP_ENABLED") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const SignUpForm = () => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
@@ -33,22 +37,19 @@ const SignUpForm = () => {
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const signUp = (email: string, username: string, password: string) => {
|
||||
authService
|
||||
const signUp = async (email: string, username: string, password: string) => {
|
||||
await authService
|
||||
.signUp(email, username, password)
|
||||
.then(() => window.location.replace("/"))
|
||||
.then(async () => {
|
||||
await refreshUser();
|
||||
router.replace("/upload");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Sign up
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
@@ -67,12 +68,12 @@ const SignUpForm = () => {
|
||||
>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="john.doe"
|
||||
placeholder="Your username"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@email.com"
|
||||
placeholder="Your email"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
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 Link from "next/link";
|
||||
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
|
||||
import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
|
||||
@@ -11,17 +11,10 @@ const ActionAvatar = () => {
|
||||
<Menu position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<Avatar size={28} radius="xl" />
|
||||
<Avatar size={28} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/account/shares"
|
||||
icon={<TbLink size={14} />}
|
||||
>
|
||||
My shares
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||
My account
|
||||
</Menu.Item>
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import Logo from "../Logo";
|
||||
import ActionAvatar from "./ActionAvatar";
|
||||
import NavbarShareMenu from "./NavbarShareMenu";
|
||||
|
||||
const HEADER_HEIGHT = 60;
|
||||
|
||||
@@ -108,46 +110,55 @@ const useStyles = createStyles((theme) => ({
|
||||
|
||||
const NavBar = () => {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [opened, toggleOpened] = useDisclosure(false);
|
||||
|
||||
const authenticatedLinks = [
|
||||
const [currentRoute, setCurrentRoute] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentRoute(router.pathname);
|
||||
}, [router.pathname]);
|
||||
|
||||
const authenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
},
|
||||
{
|
||||
component: <NavbarShareMenu />,
|
||||
},
|
||||
{
|
||||
component: <ActionAvatar />,
|
||||
},
|
||||
];
|
||||
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
||||
let unauthenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
{
|
||||
link: "/",
|
||||
label: "Home",
|
||||
},
|
||||
...array,
|
||||
]);
|
||||
if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
});
|
||||
}
|
||||
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
...array,
|
||||
{
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/",
|
||||
label: "Home",
|
||||
});
|
||||
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
unauthenticatedLinks.push({
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
});
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
const items = (
|
||||
@@ -166,7 +177,7 @@ const NavBar = () => {
|
||||
href={link.link ?? ""}
|
||||
onClick={() => toggleOpened.toggle()}
|
||||
className={cx(classes.link, {
|
||||
[classes.linkActive]: window.location.pathname == link.link,
|
||||
[classes.linkActive]: currentRoute == link.link,
|
||||
})}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
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 { TbCircleCheck, TbDownload } from "react-icons/tb";
|
||||
import shareService from "../../services/share.service";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import mime from "mime-types";
|
||||
|
||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||
import Link from "next/link";
|
||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
shareId,
|
||||
share,
|
||||
isLoading,
|
||||
}: {
|
||||
files?: any[];
|
||||
shareId: string;
|
||||
files?: FileMetaData[];
|
||||
share: Share;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const clipboard = useClipboard();
|
||||
const config = useConfig();
|
||||
const modals = useModals();
|
||||
|
||||
const copyFileLink = (file: FileMetaData) => {
|
||||
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${
|
||||
file.id
|
||||
}`;
|
||||
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your file link was copied to the keyboard.");
|
||||
} else {
|
||||
modals.openModal({
|
||||
title: "File link",
|
||||
children: (
|
||||
<Stack align="stretch">
|
||||
<TextInput variant="filled" value={link} />
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
@@ -28,24 +67,35 @@ const FileList = ({
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteStringToHumanSizeString(file.size)}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
{file.uploadingState ? (
|
||||
file.uploadingState != "finished" ? (
|
||||
<Loader size={22} />
|
||||
) : (
|
||||
<TbCircleCheck color="green" size={22} />
|
||||
)
|
||||
) : (
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/share/${share.id}/preview/${
|
||||
file.id
|
||||
}?type=${mime.contentType(file.name)}`}
|
||||
target="_blank"
|
||||
size={25}
|
||||
>
|
||||
<TbEye />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(shareId, file.id);
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
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,167 @@
|
||||
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,
|
||||
maxUseCount: 1,
|
||||
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.maxUseCount,
|
||||
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)}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Max use count"
|
||||
description="The maximum number of times this reverse share link can be used"
|
||||
{...form.getInputProps("maxUseCount")}
|
||||
/>
|
||||
{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 useConfig from "../../hooks/config.hook";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
@@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
|
||||
|
||||
const Dropzone = ({
|
||||
isUploading,
|
||||
maxShareSize,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
isUploading: boolean;
|
||||
maxShareSize: number;
|
||||
files: FileUpload[];
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
}) => {
|
||||
@@ -53,12 +55,15 @@ const Dropzone = ({
|
||||
disabled={isUploading}
|
||||
openRef={openRef as ForwardedRef<() => void>}
|
||||
onDrop={(newFiles: FileUpload[]) => {
|
||||
const fileSizeSum = [...newFiles, ...files].reduce((n, { size }) => n + size, 0);
|
||||
const fileSizeSum = [...newFiles, ...files].reduce(
|
||||
(n, { size }) => n + size,
|
||||
0
|
||||
);
|
||||
|
||||
if (fileSizeSum > config.get("MAX_SHARE_SIZE")) {
|
||||
if (fileSizeSum > maxShareSize) {
|
||||
toast.error(
|
||||
`Your files exceed the maximum share size of ${byteStringToHumanSizeString(
|
||||
config.get("MAX_SHARE_SIZE")
|
||||
`Your files exceed the maximum share size of ${byteToHumanSizeString(
|
||||
maxShareSize
|
||||
)}.`
|
||||
);
|
||||
} else {
|
||||
@@ -81,9 +86,8 @@ const Dropzone = ({
|
||||
</Text>
|
||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||
Drag'n'drop files here to start your share. We can accept
|
||||
only files that are less than{" "}
|
||||
{byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in
|
||||
total.
|
||||
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
|
||||
in total.
|
||||
</Text>
|
||||
</div>
|
||||
</MantineDropzone>
|
||||
|
||||
@@ -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 { TbTrash } from "react-icons/tb";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||
|
||||
const FileList = ({
|
||||
@@ -19,7 +19,7 @@ const FileList = ({
|
||||
const rows = files.map((file, i) => (
|
||||
<tr key={i}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteStringToHumanSizeString(file.size.toString())}</td>
|
||||
<td>{byteToHumanSizeString(file.size)}</td>
|
||||
<td>
|
||||
{file.uploadingProgress == 0 ? (
|
||||
<ActionIcon
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
@@ -24,12 +23,13 @@ import { TbAlertCircle } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { CreateShare } from "../../../types/share.type";
|
||||
import ExpirationPreview from "../ExpirationPreview";
|
||||
import { getExpirationPreview } from "../../../utils/date.util";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
appUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
@@ -54,6 +54,7 @@ const CreateUploadModalBody = ({
|
||||
uploadCallback: (createShare: CreateShare) => void;
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
appUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
@@ -89,7 +90,7 @@ const CreateUploadModalBody = ({
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
return (
|
||||
<Group>
|
||||
<>
|
||||
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||
<Alert
|
||||
withCloseButton
|
||||
@@ -161,72 +162,78 @@ const CreateUploadModalBody = ({
|
||||
{options.appUrl}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
</Text>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
{!options.isReverseShare && (
|
||||
<>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" +
|
||||
(form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label:
|
||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label:
|
||||
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label:
|
||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label:
|
||||
"Month" +
|
||||
(form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label:
|
||||
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label:
|
||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label:
|
||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label:
|
||||
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label:
|
||||
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
{/* Preview expiration date text */}
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{ExpirationPreview({ form })}
|
||||
</Text>
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{getExpirationPreview("share", form)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Accordion>
|
||||
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Description</Accordion.Control>
|
||||
@@ -296,7 +303,7 @@ const CreateUploadModalBody = ({
|
||||
<Button type="submit">Share</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import configService from "../services/config.service";
|
||||
import Config from "../types/config.type";
|
||||
import { ConfigHook } from "../types/config.type";
|
||||
|
||||
export const ConfigContext = createContext<Config[] | null>(null);
|
||||
export const ConfigContext = createContext<ConfigHook>({
|
||||
configVariables: [],
|
||||
refresh: async () => {},
|
||||
});
|
||||
|
||||
const useConfig = () => {
|
||||
const configVariables = useContext(ConfigContext) as Config[];
|
||||
const configContext = useContext(ConfigContext);
|
||||
return {
|
||||
get: (key: string) => configService.get(key, configVariables),
|
||||
get: (key: string) => configService.get(key, configContext.configVariables),
|
||||
refresh: async () => configContext.refresh(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UserHook } from "../types/user.type";
|
||||
|
||||
export const UserContext = createContext<UserHook>({
|
||||
user: null,
|
||||
setUser: () => {},
|
||||
refreshUser: async () => null,
|
||||
});
|
||||
|
||||
const useUser = () => {
|
||||
|
||||
125
frontend/src/middleware.ts
Normal file
125
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import jwtDecode from "jwt-decode";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import configService from "./services/config.service";
|
||||
|
||||
// This middleware redirects based on different conditions:
|
||||
// - Authentication state
|
||||
// - Setup status
|
||||
// - Admin privileges
|
||||
|
||||
export const config = {
|
||||
matcher: "/((?!api|static|.*\\..*|_next).*)",
|
||||
};
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const routes = {
|
||||
unauthenticated: new Routes(["/auth/*", "/"]),
|
||||
public: new Routes(["/share/*", "/upload/*"]),
|
||||
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
|
||||
admin: new Routes(["/admin/*"]),
|
||||
account: new Routes(["/account/*"]),
|
||||
disabled: new Routes([]),
|
||||
};
|
||||
|
||||
// Get config from backend
|
||||
const config = await (
|
||||
await fetch("http://localhost:8080/api/configs")
|
||||
).json();
|
||||
|
||||
const getConfig = (key: string) => {
|
||||
return configService.get(key, config);
|
||||
};
|
||||
|
||||
const route = request.nextUrl.pathname;
|
||||
let user: { isAdmin: boolean } | null = null;
|
||||
const accessToken = request.cookies.get("access_token")?.value;
|
||||
|
||||
try {
|
||||
const claims = jwtDecode<{ exp: number; isAdmin: boolean }>(
|
||||
accessToken as string
|
||||
);
|
||||
if (claims.exp * 1000 > Date.now()) {
|
||||
user = claims;
|
||||
}
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (!getConfig("ALLOW_REGISTRATION")) {
|
||||
routes.disabled.routes.push("/auth/signUp");
|
||||
}
|
||||
|
||||
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
routes.public.routes = ["*"];
|
||||
}
|
||||
|
||||
if (!getConfig("SMTP_ENABLED")) {
|
||||
routes.disabled.routes.push("/auth/resetPassword*");
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const rules = [
|
||||
// Disabled routes
|
||||
{
|
||||
condition: routes.disabled.contains(route),
|
||||
path: "/",
|
||||
},
|
||||
// Setup status
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
|
||||
path: "/auth/signUp",
|
||||
},
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route),
|
||||
path: user ? "/admin/setup" : "/auth/signIn",
|
||||
},
|
||||
// Authenticated state
|
||||
{
|
||||
condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"),
|
||||
path: "/upload",
|
||||
},
|
||||
// Unauthenticated state
|
||||
{
|
||||
condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route),
|
||||
path: "/auth/signIn",
|
||||
},
|
||||
{
|
||||
condition: !user && routes.account.contains(route),
|
||||
path: "/upload",
|
||||
},
|
||||
// Admin privileges
|
||||
{
|
||||
condition: routes.admin.contains(route) && !user?.isAdmin,
|
||||
path: "/upload",
|
||||
},
|
||||
// Home page
|
||||
{
|
||||
condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/",
|
||||
path: "/upload",
|
||||
},
|
||||
];
|
||||
for (const rule of rules) {
|
||||
if (rule.condition) {
|
||||
let { path } = rule;
|
||||
|
||||
if (path == "/auth/signIn") {
|
||||
path = path + "?redirect=" + encodeURIComponent(route);
|
||||
}
|
||||
return NextResponse.redirect(new URL(path, request.url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class to check if a route matches a list of routes
|
||||
class Routes {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
constructor(public routes: string[]) {}
|
||||
|
||||
contains(_route: string) {
|
||||
for (const route of this.routes) {
|
||||
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(_route))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,15 @@ import {
|
||||
ColorScheme,
|
||||
ColorSchemeProvider,
|
||||
Container,
|
||||
LoadingOverlay,
|
||||
MantineProvider,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import axios from "axios";
|
||||
import { getCookie, setCookie } from "cookies-next";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import { ConfigContext } from "../hooks/config.hook";
|
||||
@@ -22,48 +23,38 @@ import GlobalStyle from "../styles/global.style";
|
||||
import globalStyle from "../styles/mantine.style";
|
||||
import Config from "../types/config.type";
|
||||
import { CurrentUser } from "../types/user.type";
|
||||
import { GlobalLoadingContext } from "../utils/loading.util";
|
||||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const systemTheme = useColorScheme();
|
||||
const router = useRouter();
|
||||
const systemTheme = useColorScheme(pageProps.colorScheme);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme);
|
||||
const preferences = usePreferences();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
||||
|
||||
const getInitalData = async () => {
|
||||
setIsLoading(true);
|
||||
setConfigVariables(await configService.list());
|
||||
await authService.refreshAccessToken();
|
||||
setUser(await userService.getCurrentUser());
|
||||
setIsLoading(false);
|
||||
};
|
||||
const [user, setUser] = useState<CurrentUser | null>(pageProps.user);
|
||||
|
||||
const [configVariables, setConfigVariables] = useState<Config[]>(
|
||||
pageProps.configVariables
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
|
||||
getInitalData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
configVariables &&
|
||||
configVariables.filter((variable) => variable.key)[0].value == "false" &&
|
||||
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
|
||||
) {
|
||||
router.push(!user ? "/auth/signUp" : "/admin/setup");
|
||||
}
|
||||
}, [router.asPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(
|
||||
const colorScheme =
|
||||
preferences.get("colorScheme") == "system"
|
||||
? systemTheme
|
||||
: preferences.get("colorScheme")
|
||||
);
|
||||
: preferences.get("colorScheme");
|
||||
|
||||
toggleColorScheme(colorScheme);
|
||||
}, [systemTheme]);
|
||||
|
||||
const toggleColorScheme = (value: ColorScheme) => {
|
||||
setColorScheme(value ?? "light");
|
||||
setCookie("mantine-color-scheme", value ?? "light", {
|
||||
sameSite: "lax",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
@@ -72,26 +63,35 @@ function App({ Component, pageProps }: AppProps) {
|
||||
>
|
||||
<ColorSchemeProvider
|
||||
colorScheme={colorScheme}
|
||||
toggleColorScheme={(value) => setColorScheme(value ?? "light")}
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
>
|
||||
<GlobalStyle />
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay visible overlayOpacity={1} />
|
||||
) : (
|
||||
<ConfigContext.Provider value={configVariables}>
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
)}
|
||||
</GlobalLoadingContext.Provider>
|
||||
<ConfigContext.Provider
|
||||
value={{
|
||||
configVariables,
|
||||
refresh: async () => {
|
||||
setConfigVariables(await configService.list());
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user,
|
||||
refreshUser: async () => {
|
||||
const user = await userService.getCurrentUser();
|
||||
setUser(user);
|
||||
return user;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</ColorSchemeProvider>
|
||||
@@ -99,4 +99,33 @@ function App({ Component, pageProps }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch user and config variables on server side when the first request is made
|
||||
// These will get passed as a page prop to the App component and stored in the contexts
|
||||
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||
let pageProps: {
|
||||
user?: CurrentUser;
|
||||
configVariables?: Config[];
|
||||
colorScheme: ColorScheme;
|
||||
} = {
|
||||
colorScheme:
|
||||
(getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light",
|
||||
};
|
||||
|
||||
if (ctx.req) {
|
||||
const cookieHeader = ctx.req.headers.cookie;
|
||||
|
||||
pageProps.user = await axios(`http://localhost:8080/api/users/me`, {
|
||||
headers: { cookie: cookieHeader },
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.catch(() => null);
|
||||
|
||||
pageProps.configVariables = (
|
||||
await axios(`http://localhost:8080/api/configs`)
|
||||
).data;
|
||||
}
|
||||
|
||||
return { pageProps };
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -13,20 +13,19 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useRouter } from "next/router";
|
||||
import { Tb2Fa } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
||||
import Meta from "../../components/Meta";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import userService from "../../services/user.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Account = () => {
|
||||
const { user, setUser } = useUser();
|
||||
const { user, refreshUser } = useUser();
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
@@ -82,194 +81,193 @@ const Account = () => {
|
||||
),
|
||||
});
|
||||
|
||||
const refreshUser = async () => setUser(await userService.getCurrentUser());
|
||||
|
||||
if (!user) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm">
|
||||
<Title order={3} mb="xs">
|
||||
My account
|
||||
</Title>
|
||||
<Paper withBorder p="xl">
|
||||
<Title order={5} mb="xs">
|
||||
Account Info
|
||||
<>
|
||||
<Meta title="My account" />
|
||||
<Container size="sm">
|
||||
<Title order={3} mb="xs">
|
||||
My account
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={accountForm.onSubmit((values) =>
|
||||
userService
|
||||
.updateCurrentUser({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
})
|
||||
.then(() => toast.success("User updated successfully"))
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput label="Email" {...accountForm.getInputProps("email")} />
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Password
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit((values) =>
|
||||
authService
|
||||
.updatePassword(values.oldPassword, values.password)
|
||||
.then(() => {
|
||||
toast.success("Password updated successfully");
|
||||
passwordForm.reset();
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Old password"
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Security
|
||||
</Title>
|
||||
|
||||
<Tabs defaultValue="totp">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
|
||||
TOTP
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="totp" pt="xs">
|
||||
{user.totpVerified ? (
|
||||
<>
|
||||
<form
|
||||
onSubmit={disableTotpForm.onSubmit((values) => {
|
||||
authService
|
||||
.disableTOTP(values.code, values.password)
|
||||
.then(() => {
|
||||
toast.success("Successfully disabled TOTP");
|
||||
values.password = "";
|
||||
values.code = "";
|
||||
refreshUser();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
description="Enter your current password to disable TOTP"
|
||||
label="Password"
|
||||
{...disableTotpForm.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Code"
|
||||
placeholder="******"
|
||||
{...disableTotpForm.getInputProps("code")}
|
||||
/>
|
||||
|
||||
<Group position="right">
|
||||
<Button color="red" type="submit">
|
||||
Disable
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form
|
||||
onSubmit={enableTotpForm.onSubmit((values) => {
|
||||
authService
|
||||
.enableTOTP(values.password)
|
||||
.then((result) => {
|
||||
showEnableTotpModal(modals, refreshUser, {
|
||||
qrCode: result.qrCode,
|
||||
secret: result.totpSecret,
|
||||
password: values.password,
|
||||
});
|
||||
values.password = "";
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
description="Enter your current password to start enabling TOTP"
|
||||
{...enableTotpForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Start</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
<Paper withBorder p="xl">
|
||||
<Title order={5} mb="xs">
|
||||
Account Info
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={accountForm.onSubmit((values) =>
|
||||
userService
|
||||
.updateCurrentUser({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
})
|
||||
.then(() => toast.success("User updated successfully"))
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Color scheme
|
||||
</Title>
|
||||
<ThemeSwitcher />
|
||||
</Paper>
|
||||
<Center mt={80} mb="lg">
|
||||
<Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
modals.openConfirmModal({
|
||||
title: "Account deletion",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete your account including all your
|
||||
active shares?
|
||||
</Text>
|
||||
),
|
||||
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await userService.removeCurrentUser();
|
||||
window.location.reload();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
{...accountForm.getInputProps("email")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Password
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit((values) =>
|
||||
authService
|
||||
.updatePassword(values.oldPassword, values.password)
|
||||
.then(() => {
|
||||
toast.success("Password updated successfully");
|
||||
passwordForm.reset();
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Old password"
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Security
|
||||
</Title>
|
||||
|
||||
<Tabs defaultValue="totp">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
|
||||
TOTP
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="totp" pt="xs">
|
||||
{user!.totpVerified ? (
|
||||
<>
|
||||
<form
|
||||
onSubmit={disableTotpForm.onSubmit((values) => {
|
||||
authService
|
||||
.disableTOTP(values.code, values.password)
|
||||
.then(() => {
|
||||
toast.success("Successfully disabled TOTP");
|
||||
values.password = "";
|
||||
values.code = "";
|
||||
refreshUser();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
description="Enter your current password to disable TOTP"
|
||||
label="Password"
|
||||
{...disableTotpForm.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Code"
|
||||
placeholder="******"
|
||||
{...disableTotpForm.getInputProps("code")}
|
||||
/>
|
||||
|
||||
<Group position="right">
|
||||
<Button color="red" type="submit">
|
||||
Disable
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form
|
||||
onSubmit={enableTotpForm.onSubmit((values) => {
|
||||
authService
|
||||
.enableTOTP(values.password)
|
||||
.then((result) => {
|
||||
showEnableTotpModal(modals, refreshUser, {
|
||||
qrCode: result.qrCode,
|
||||
secret: result.totpSecret,
|
||||
password: values.password,
|
||||
});
|
||||
values.password = "";
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
description="Enter your current password to start enabling TOTP"
|
||||
{...enableTotpForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Start</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Color scheme
|
||||
</Title>
|
||||
<ThemeSwitcher />
|
||||
</Paper>
|
||||
<Center mt={80} mb="lg">
|
||||
<Stack>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
modals.openConfirmModal({
|
||||
title: "Account deletion",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete your account including all
|
||||
your active shares?
|
||||
</Text>
|
||||
),
|
||||
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await userService.removeCurrentUser();
|
||||
window.location.reload();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
211
frontend/src/pages/account/reverseShares.tsx
Normal file
211
frontend/src/pages/account/reverseShares.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Accordion,
|
||||
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 { 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 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 config = useConfig();
|
||||
|
||||
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
|
||||
|
||||
const getReverseShares = () => {
|
||||
shareService
|
||||
.getMyReverseShares()
|
||||
.then((shares) => setReverseShares(shares));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getReverseShares();
|
||||
}, []);
|
||||
|
||||
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 that allows external users to create a share."
|
||||
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>Shares</th>
|
||||
<th>Remaining uses</th>
|
||||
<th>Max share size</th>
|
||||
<th>Expires at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reverseShares.map((reverseShare) => (
|
||||
<tr key={reverseShare.id}>
|
||||
<td style={{ width: 220 }}>
|
||||
{reverseShare.shares.length == 0 ? (
|
||||
<Text color="dimmed" size="sm">
|
||||
No shares created yet
|
||||
</Text>
|
||||
) : (
|
||||
<Accordion>
|
||||
<Accordion.Item
|
||||
value="customization"
|
||||
sx={{ borderBottom: "none" }}
|
||||
>
|
||||
<Accordion.Control p={0}>
|
||||
<Text size="sm">
|
||||
{`${reverseShare.shares.length} share${
|
||||
reverseShare.shares.length > 1 ? "s" : ""
|
||||
}`}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{reverseShare.shares.map((share) => (
|
||||
<Group key={share.id} mb={4}>
|
||||
<Text maw={120} truncate>
|
||||
{share.id}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
color="victoria"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("APP_URL")}/share/${
|
||||
share.id
|
||||
}`
|
||||
);
|
||||
toast.success(
|
||||
"The share link was copied to the keyboard."
|
||||
);
|
||||
} else {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
share.id,
|
||||
config.get("APP_URL")
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</td>
|
||||
<td>{reverseShare.remainingUses}</td>
|
||||
<td>
|
||||
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
|
||||
</td>
|
||||
<td>
|
||||
{moment(reverseShare.shareExpiration).unix() === 0
|
||||
? "Never"
|
||||
: moment(reverseShare.shareExpiration).format("LLL")}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<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 associated shares will be deleted
|
||||
as well.
|
||||
</Text>
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
labels: { confirm: "Delete", 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,9 +1,9 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Space,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -14,13 +14,12 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbLink, TbTrash } from "react-icons/tb";
|
||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||
import CenterLoader from "../../components/core/CenterLoader";
|
||||
import Meta from "../../components/Meta";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { MyShare } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -28,39 +27,35 @@ import toast from "../../utils/toast.util";
|
||||
const MyShares = () => {
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const [shares, setShares] = useState<MyShare[]>();
|
||||
|
||||
useEffect(() => {
|
||||
shareService.getMyShares().then((shares) => setShares(shares));
|
||||
}, []);
|
||||
|
||||
if (!user) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
if (!shares) return <LoadingOverlay visible />;
|
||||
return (
|
||||
<>
|
||||
<Meta title="My shares" />
|
||||
<Title mb={30} order={3}>
|
||||
My shares
|
||||
</Title>
|
||||
{shares.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 shares.</Text>
|
||||
<Space h={5} />
|
||||
<Button component={Link} href="/upload" variant="light">
|
||||
Create one
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
if (!shares) return <CenterLoader />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="My shares" />
|
||||
<Title mb={30} order={3}>
|
||||
My shares
|
||||
</Title>
|
||||
{shares.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 shares.</Text>
|
||||
<Space h={5} />
|
||||
<Button component={Link} href="/upload" variant="light">
|
||||
Create one
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -138,10 +133,10 @@ const MyShares = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyShares;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Space, Title } from "@mantine/core";
|
||||
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||
import Meta from "../../components/Meta";
|
||||
|
||||
const AdminConfig = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Configuration" />
|
||||
<Title mb={30} order={3}>
|
||||
Configuration
|
||||
</Title>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
|
||||
import Meta from "../../components/Meta";
|
||||
import configService from "../../services/config.service";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
@@ -53,7 +54,7 @@ const Admin = () => {
|
||||
title: "Update",
|
||||
icon: TbRefresh,
|
||||
route:
|
||||
"https://github.com/stonith404/pingvin-share/releases/tag/v0.5.0",
|
||||
"https://github.com/stonith404/pingvin-share/releases/latest",
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -62,6 +63,7 @@ const Admin = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Administration" />
|
||||
<Title mb={30} order={3}>
|
||||
Administration
|
||||
</Title>
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { useRouter } from "next/router";
|
||||
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||
|
||||
import Logo from "../../components/Logo";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import Meta from "../../components/Meta";
|
||||
|
||||
const Setup = () => {
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
const { user } = useUser();
|
||||
|
||||
if (!user) {
|
||||
router.push("/auth/signUp");
|
||||
return;
|
||||
} else if (config.get("SETUP_FINISHED")) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Setup" />
|
||||
<Stack align="center">
|
||||
<Logo height={80} width={80} />
|
||||
<Title order={2}>Welcome to Pingvin Share</Title>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { TbPlus } from "react-icons/tb";
|
||||
import ManageUserTable from "../../components/admin/ManageUserTable";
|
||||
import showCreateUserModal from "../../components/admin/showCreateUserModal";
|
||||
import Meta from "../../components/Meta";
|
||||
import userService from "../../services/user.service";
|
||||
import User from "../../types/user.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -47,6 +48,7 @@ const Users = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="User management" />
|
||||
<Group position="apart" align="baseline" mb={20}>
|
||||
<Title mb={30} order={3}>
|
||||
User management
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useRouter } from "next/router";
|
||||
import * as yup from "yup";
|
||||
import authService from "../../../services/auth.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
control: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const ResetPassword = () => {
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
password: yup.string().min(8).required(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const resetPasswordToken = router.query.resetPasswordToken as string;
|
||||
|
||||
return (
|
||||
<Container size={460} my={30}>
|
||||
<Title order={2} weight={900} align="center">
|
||||
Reset password
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" align="center">
|
||||
Enter your new password
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
console.log(resetPasswordToken);
|
||||
authService
|
||||
.resetPassword(resetPasswordToken, values.password)
|
||||
.then(() => {
|
||||
toast.success("Your password has been reset successfully.");
|
||||
|
||||
router.push("/auth/signIn");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="••••••••••"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right" mt="lg">
|
||||
<Button type="submit" className={classes.control}>
|
||||
Reset password
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
107
frontend/src/pages/auth/resetPassword/index.tsx
Normal file
107
frontend/src/pages/auth/resetPassword/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbArrowLeft } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import authService from "../../../services/auth.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: 900,
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
},
|
||||
|
||||
controls: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
flexDirection: "column-reverse",
|
||||
},
|
||||
},
|
||||
|
||||
control: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const ResetPassword = () => {
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size={460} my={30}>
|
||||
<Title order={2} weight={900} align="center">
|
||||
Forgot your password?
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" align="center">
|
||||
Enter your email to get a reset link
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
authService
|
||||
.requestResetPassword(values.email)
|
||||
.then(() => {
|
||||
toast.success("The email has been sent.");
|
||||
router.push("/auth/signIn");
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Your email"
|
||||
placeholder="Your email"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<Group position="apart" mt="lg" className={classes.controls}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
color="dimmed"
|
||||
size="sm"
|
||||
className={classes.control}
|
||||
href={"/auth/signIn"}
|
||||
>
|
||||
<Center inline>
|
||||
<TbArrowLeft size={12} />
|
||||
<Box ml={5}>Back to login page</Box>
|
||||
</Center>
|
||||
</Anchor>
|
||||
<Button type="submit" className={classes.control}>
|
||||
Reset password
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -1,20 +1,42 @@
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import SignInForm from "../../components/auth/SignInForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
const SignIn = () => {
|
||||
const { user } = useUser();
|
||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: { redirectPath: context.query.redirect ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
const SignIn = ({ redirectPath }: { redirectPath?: string }) => {
|
||||
const { refreshUser } = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign In" />
|
||||
<SignInForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const [isLoading, setIsLoading] = useState(redirectPath ? true : false);
|
||||
|
||||
// If the access token is expired, the middleware redirects to this page.
|
||||
// If the refresh token is still valid, the user will be redirected to the last page.
|
||||
useEffect(() => {
|
||||
refreshUser().then((user) => {
|
||||
if (user) {
|
||||
router.replace(redirectPath ?? "/upload");
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <LoadingOverlay overlayOpacity={1} visible />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign In" />
|
||||
<SignInForm redirectPath={redirectPath ?? "/upload"} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SignIn;
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { useRouter } from "next/router";
|
||||
import SignUpForm from "../../components/auth/SignUpForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
const SignUp = () => {
|
||||
const config = useConfig();
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
} else if (!config.get("ALLOW_REGISTRATION")) {
|
||||
router.replace("/auth/signIn");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign Up" />
|
||||
<SignUpForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign Up" />
|
||||
<SignUpForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SignUp;
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { TbCheck } from "react-icons/tb";
|
||||
import Meta from "../components/Meta";
|
||||
import useConfig from "../hooks/config.hook";
|
||||
import useUser from "../hooks/user.hook";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
@@ -69,94 +69,96 @@ const useStyles = createStyles((theme) => ({
|
||||
}));
|
||||
|
||||
export default function Home() {
|
||||
const config = useConfig();
|
||||
const { user } = useUser();
|
||||
|
||||
const { classes } = useStyles();
|
||||
const { refreshUser } = useUser();
|
||||
const router = useRouter();
|
||||
if (user || config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
router.replace("/upload");
|
||||
} else if (!config.get("SHOW_HOME_PAGE")) {
|
||||
router.replace("/auth/signIn");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Home" />
|
||||
<Container>
|
||||
<div className={classes.inner}>
|
||||
<div className={classes.content}>
|
||||
<Title className={classes.title}>
|
||||
A <span className={classes.highlight}>self-hosted</span> <br />{" "}
|
||||
file sharing platform.
|
||||
</Title>
|
||||
<Text color="dimmed" mt="md">
|
||||
Do you really want to give your personal files in the hand of
|
||||
third parties like WeTransfer?
|
||||
</Text>
|
||||
|
||||
<List
|
||||
mt={30}
|
||||
spacing="sm"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon size={20} radius="xl">
|
||||
<TbCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
// If the user is already logged in, redirect to the upload page
|
||||
useEffect(() => {
|
||||
refreshUser().then((user) => {
|
||||
if (user) {
|
||||
router.replace("/upload");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Home" />
|
||||
<Container>
|
||||
<div className={classes.inner}>
|
||||
<div className={classes.content}>
|
||||
<Title className={classes.title}>
|
||||
A <span className={classes.highlight}>self-hosted</span> <br />{" "}
|
||||
file sharing platform.
|
||||
</Title>
|
||||
<Text color="dimmed" mt="md">
|
||||
Do you really want to give your personal files in the hand of
|
||||
third parties like WeTransfer?
|
||||
</Text>
|
||||
|
||||
<List
|
||||
mt={30}
|
||||
spacing="sm"
|
||||
size="sm"
|
||||
icon={
|
||||
<ThemeIcon size={20} radius="xl">
|
||||
<TbCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>Self-Hosted</b> - Host Pingvin Share on your own machine.
|
||||
</div>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>Privacy</b> - Your files are your files and should never
|
||||
get into the hands of third parties.
|
||||
</div>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>No annoying file size limit</b> - Upload as big files as
|
||||
you want. Only your hard drive will be your limit.
|
||||
</div>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<Group mt={30}>
|
||||
<Button
|
||||
component={Link}
|
||||
href="/auth/signUp"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className={classes.control}
|
||||
>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>Self-Hosted</b> - Host Pingvin Share on your own machine.
|
||||
</div>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>Privacy</b> - Your files are your files and should never
|
||||
get into the hands of third parties.
|
||||
</div>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div>
|
||||
<b>No annoying file size limit</b> - Upload as big files as
|
||||
you want. Only your hard drive will be your limit.
|
||||
</div>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<Group mt={30}>
|
||||
<Button
|
||||
component={Link}
|
||||
href="/auth/signUp"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className={classes.control}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="https://github.com/stonith404/pingvin-share"
|
||||
target="_blank"
|
||||
variant="default"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className={classes.control}
|
||||
>
|
||||
Source code
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
<Group className={classes.image} align="center">
|
||||
<Image
|
||||
src="/img/logo.svg"
|
||||
alt="Pingvin Share Logo"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
Get started
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="https://github.com/stonith404/pingvin-share"
|
||||
target="_blank"
|
||||
variant="default"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className={classes.control}
|
||||
>
|
||||
Source code
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<Group className={classes.image} align="center">
|
||||
<Image
|
||||
src="/img/logo.svg"
|
||||
alt="Pingvin Share Logo"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Box, Group, Text, Title } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useEffect, useState } from "react";
|
||||
import Meta from "../../components/Meta";
|
||||
import DownloadAllButton from "../../components/share/DownloadAllButton";
|
||||
import FileList from "../../components/share/FileList";
|
||||
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
|
||||
import showErrorModal from "../../components/share/showErrorModal";
|
||||
import shareService from "../../services/share.service";
|
||||
import { Share as ShareType } from "../../types/share.type";
|
||||
import Meta from "../../../components/Meta";
|
||||
import DownloadAllButton from "../../../components/share/DownloadAllButton";
|
||||
import FileList from "../../../components/share/FileList";
|
||||
import showEnterPasswordModal from "../../../components/share/showEnterPasswordModal";
|
||||
import showErrorModal from "../../../components/share/showErrorModal";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { Share as ShareType } from "../../../types/share.type";
|
||||
|
||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
@@ -47,21 +47,19 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
.catch((e) => {
|
||||
const { error } = e.response.data;
|
||||
if (e.response.status == 404) {
|
||||
showErrorModal(
|
||||
modals,
|
||||
"Not found",
|
||||
"This share can't be found. Please check your link."
|
||||
);
|
||||
if (error == "share_removed") {
|
||||
showErrorModal(modals, "Share removed", e.response.data.message);
|
||||
} else {
|
||||
showErrorModal(
|
||||
modals,
|
||||
"Not found",
|
||||
"This share can't be found. Please check your link."
|
||||
);
|
||||
}
|
||||
} else if (error == "share_password_required") {
|
||||
showEnterPasswordModal(modals, getShareToken);
|
||||
} else if (error == "share_token_required") {
|
||||
getShareToken();
|
||||
} else if (error == "forbidden") {
|
||||
showErrorModal(
|
||||
modals,
|
||||
"Forbidden",
|
||||
"You're not allowed to see this share. Are you logged in with the correct account?"
|
||||
);
|
||||
} else {
|
||||
showErrorModal(modals, "Error", "An unknown error occurred.");
|
||||
}
|
||||
@@ -87,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
|
||||
</Group>
|
||||
|
||||
<FileList files={share?.files} shareId={shareId} isLoading={!share} />
|
||||
<FileList files={share?.files} share={share!} isLoading={!share} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user