Compare commits

...

46 Commits

Author SHA1 Message Date
Elias Schneider
3d5c919110 release: 0.9.0 2023-01-31 15:25:01 +01:00
Elias Schneider
008df06b5c feat: direct file link 2023-01-31 15:22:08 +01:00
Elias Schneider
cd9d828686 refactor: move guard checks to service 2023-01-31 13:53:23 +01:00
Elias Schneider
233c26e5cf fix: improve send test email UX 2023-01-31 13:16:11 +01:00
Elias Schneider
91a6b3f716 feat: file preview 2023-01-31 09:03:03 +01:00
Elias Schneider
0a2b7b1243 refactor: use cookie instead of local storage for share token 2023-01-26 21:18:22 +01:00
Elias Schneider
b98fe7911f release: 0.8.0 2023-01-26 16:10:16 +01:00
Elias Schneider
ad92cfc852 fix: admin users were created while the setup wizard wasn't finished 2023-01-26 15:43:13 +01:00
Elias Schneider
7e91038a24 chore: optimize prisma migration 2023-01-26 14:06:25 +01:00
Elias Schneider
4a5fb549c6 feat: reverse shares (#86)
* add first concept

* add reverse share funcionality to frontend

* allow creator to limit share expiration

* moved reverse share in seperate module

* add table to manage reverse shares

* delete complete share if reverse share was deleted

* optimize function names

* add db migration

* enable reverse share email notifications

* fix config variable descriptions

* fix migration for new installations
2023-01-26 13:44:04 +01:00
Elias Schneider
1ceb07b89e refactor: fix typo of service name 2023-01-17 09:48:49 +01:00
Elias Schneider
bb64f6c33f fix: Add meta tags to new pages 2023-01-17 09:13:53 +01:00
Elias Schneider
61c48d57b8 ci/cd: upgrade github actions 2023-01-13 15:37:49 +01:00
Luke
2a7587ed78 chore: docker compose ClamAV optimizations
* Update docker-compose.yml

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

* Update README.md

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

* minor refactoring

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-01-13 14:11:33 +01:00
Elias Schneider
e09213a295 release: 0.7.0 2023-01-13 10:59:52 +01:00
Elias Schneider
fc116d65c0 chore: dump packages 2023-01-13 10:31:22 +01:00
Elias Schneider
76088cc76a feat: add ClamAV to scan for malicious files 2023-01-13 10:16:35 +01:00
Elias Schneider
16b697053a ci/cd: don't stale feature issues 2023-01-12 13:47:09 +01:00
Elias Schneider
349bf475cc fix: invalid github release link on admin page 2023-01-11 22:32:37 +01:00
Elias Schneider
fccc4cbc02 release: 0.6.1 2023-01-11 13:08:09 +01:00
Elias Schneider
f1b44f87fa fix: shareUrl uses wrong origin 2023-01-11 13:06:38 +01:00
Elias Schneider
02e41e2437 feat: delete all sessions if password was changed 2023-01-10 13:32:37 +01:00
Elias Schneider
74e8956106 fix: update password doesn't work 2023-01-10 12:29:38 +01:00
Elias Schneider
dc9ec429c6 release: 0.6.0 2023-01-09 12:14:41 +01:00
Elias Schneider
653d72bcb9 feat: chunk uploads (#76)
* add first concept

* finished first concept

* allow 3 uploads at same time

* retry if chunk failed

* updated clean temporary files job

* fix throttling for chunk uploads

* update tests

* remove multer

* migrate from `MAX_FILE_SIZE` to `MAX_SHARE_SIZE`

* improve error handling if file failed to upload

* fix promise limit

* improve file progress
2023-01-09 11:43:48 +01:00
Elias Schneider
a5bef5d4a4 fix: refresh token expires after 1 day instead of 3 months 2023-01-07 12:16:03 +01:00
Elias Schneider
c8ad2225e3 fix: access token refreshes even it is still valid 2023-01-06 16:07:07 +01:00
Elias Schneider
72c8081e7c fix: error message typo 2023-01-06 09:21:46 +01:00
Elias Schneider
f2d4895e50 fix: migration for v0.5.1 2023-01-05 08:34:31 +01:00
Elias Schneider
54f591cd60 release: 0.5.1 2023-01-04 16:02:54 +01:00
Elias Schneider
f836a0a3cd chore: add db migration 2023-01-04 15:58:15 +01:00
Elias Schneider
11174656e4 fix: email configuration updated without restart 2023-01-04 15:30:49 +01:00
Elias Schneider
faea1abcc4 feat: use cookies for authentication 2023-01-04 11:54:28 +01:00
Elias Schneider
71658ad39d feat: show version and show button if new release is available on admin page 2022-12-30 19:23:17 +01:00
Elias Schneider
167f0f8c7a chore: improve release scripts 2022-12-30 18:59:05 +01:00
Elias Schneider
85551dc3d3 release: 0.5.0 2022-12-30 14:41:23 +01:00
Elias Schneider
5bc4f902f6 feat: improve config UI (#69)
* add first concept

* completed configuration ui update

* add button for testing email configuration

* improve mobile layout

* add migration

* run formatter

* delete unnecessary modal

* remove unused comment
2022-12-30 14:40:23 +01:00
Elias Schneider
e5b50f855c fix: refresh token gets deleted on session end 2022-12-26 12:57:54 +01:00
Elias Schneider
b73144295b refactor: extract totp operations in seperate service 2022-12-26 12:43:36 +01:00
Elias Schneider
ef21bac59b feat: manually switch color scheme 2022-12-24 23:58:31 +01:00
Elias Schneider
cabaee588b feat: custom mail subject 2022-12-23 10:57:09 +01:00
Elias Schneider
aac363bb37 release: 0.4.0 2022-12-21 18:25:00 +01:00
Elias Schneider
af71317ec4 Merge remote-tracking branch 'origin/main' into main 2022-12-21 18:01:06 +01:00
Steve
16480f6e95 feat: TOTP (two-factor) Authentication (#55)
* Working on some initial prototype stuff for TOTP

* Fixed a bug that prevented the change password menu from working

* Enable/disable totp working

* Added the new login procedure including TOTP! :)

* misc: Changed bad description for the TOTP_SECRET env var

* I forgot to include the migration for the new prisma stuff

* fix: refresh user context instead refreshing the page

* refactor: simplify totp error handling

* Removed U2F tab + format schema

* fix: tokens not saved in cookies

* refactor: deleted commented out code

* refactor: move password text to input description

* refactor: remove tabler icon package

Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Elias Schneider <58886915+stonith404@users.noreply.github.com>
2022-12-21 17:58:37 +01:00
Elias Schneider
1a034a1966 refector: remove unnecessary content type header 2022-12-15 21:50:22 +01:00
Elias Schneider
0616a68bd2 feat: custom email message 2022-12-15 21:44:04 +01:00
122 changed files with 5590 additions and 2477 deletions

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ jobs:
with: with:
days-before-issue-stale: 30 days-before-issue-stale: 30
days-before-issue-close: 14 days-before-issue-close: 14
exempt-issue-labels: "feature"
stale-issue-label: "stale" stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 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." close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."

View File

@@ -1,3 +1,104 @@
## [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)
### Features
* delete all sessions if password was changed ([02e41e2](https://github.com/stonith404/pingvin-share/commit/02e41e243768de34de1bdc8833e83f60db530e55))
### Bug Fixes
* shareUrl uses wrong origin ([f1b44f8](https://github.com/stonith404/pingvin-share/commit/f1b44f87fa64d3b21ca92c9068cb352d0ad51bc0))
* update password doesn't work ([74e8956](https://github.com/stonith404/pingvin-share/commit/74e895610642552c98c0015d0f8347735aaed457))
## [0.6.0](https://github.com/stonith404/pingvin-share/compare/v0.5.1...v0.6.0) (2023-01-09)
### Features
* chunk uploads ([#76](https://github.com/stonith404/pingvin-share/issues/76)) ([653d72b](https://github.com/stonith404/pingvin-share/commit/653d72bcb958268e2f23efae94cccb72faa745af))
### Bug Fixes
* access token refreshes even it is still valid ([c8ad222](https://github.com/stonith404/pingvin-share/commit/c8ad2225e3c9ca79fea494d538b67797fbc7f6ae))
* error message typo ([72c8081](https://github.com/stonith404/pingvin-share/commit/72c8081e7c135ab1f600ed7e3d7a0bf03dabde34))
* migration for v0.5.1 ([f2d4895](https://github.com/stonith404/pingvin-share/commit/f2d4895e50d3da82cef68858752fb7f6293e7a20))
* refresh token expires after 1 day instead of 3 months ([a5bef5d](https://github.com/stonith404/pingvin-share/commit/a5bef5d4a4ae75447ca1f65259c5541edfc87dd8))
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
### Features
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
### Bug Fixes
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
### Features
* custom mail subject ([cabaee5](https://github.com/stonith404/pingvin-share/commit/cabaee588b50877872d210c870bfb9c95b541921))
* improve config UI ([#69](https://github.com/stonith404/pingvin-share/issues/69)) ([5bc4f90](https://github.com/stonith404/pingvin-share/commit/5bc4f902f6218a09423491404806a4b7fb865c98))
* manually switch color scheme ([ef21bac](https://github.com/stonith404/pingvin-share/commit/ef21bac59b11dc68649ab3b195dcb89d2b192e7b))
### Bug Fixes
* refresh token gets deleted on session end ([e5b50f8](https://github.com/stonith404/pingvin-share/commit/e5b50f855c02aa4b5c9ee873dd5a7ab25759972d))
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
### Features
* custom email message ([0616a68](https://github.com/stonith404/pingvin-share/commit/0616a68bd2e0c9cb559ebdf294e353dd3f69c9a5))
* TOTP (two-factor) Authentication ([#55](https://github.com/stonith404/pingvin-share/issues/55)) ([16480f6](https://github.com/stonith404/pingvin-share/commit/16480f6e9572011fadeb981a388b92cb646fa6d9))
### [0.3.6](https://github.com/stonith404/pingvin-share/compare/v0.3.5...v0.3.6) (2022-12-13) ### [0.3.6](https://github.com/stonith404/pingvin-share/compare/v0.3.5...v0.3.6) (2022-12-13)

View File

@@ -30,7 +30,7 @@ RUN npm run build && npm prune --production
# Stage 5: Final image # Stage 5: Final image
FROM node:18-slim AS runner FROM node:18-slim AS runner
ENV NODE_ENV=production ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y openssl RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app/frontend WORKDIR /opt/app/frontend

View File

@@ -4,13 +4,12 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
## ✨ Features ## ✨ Features
- Spin up your instance within 2 minutes
- Create a share with files that you can access with a link - Create a share with files that you can access with a link
- No file size limit, only your disk will be your limit - No file size limit, only your disk will be your limit
- Set a share expiration - Set a share expiration
- Optionally secure your share with a visitor limit and a password - Optionally secure your share with a visitor limit and a password
- Email recepients - Email recepients
- Light & dark mode - ClamAV integration
## 🐧 Get to know Pingvin Share ## 🐧 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 🐧! 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 ### Additional resources
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) - [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)

1710
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.0.1", "version": "0.9.0",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "nest start --watch", "dev": "nest start --watch",
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main", "prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --write 'src/**/*.ts'",
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/system/newman-system-tests.json" "test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
}, },
"prisma": { "prisma": {
"seed": "ts-node prisma/seed/config.seed.ts" "seed": "ts-node prisma/seed/config.seed.ts"
@@ -16,56 +16,62 @@
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^9.0.0", "@nestjs/jwt": "^10.0.1",
"@nestjs/mapped-types": "^1.2.0", "@nestjs/mapped-types": "^1.2.0",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^2.1.0",
"@nestjs/throttler": "^3.1.0", "@nestjs/throttler": "^3.1.0",
"@prisma/client": "^4.7.1", "@prisma/client": "^4.8.1",
"archiver": "^5.3.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-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "nodemailer": "^6.9.0",
"nodemailer": "^6.8.0", "otplib": "^12.0.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^4.0.4",
"rxjs": "^7.6.0", "rxjs": "^7.8.0",
"ts-node": "^10.9.1" "ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.5", "@nestjs/cli": "^9.1.8",
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.4",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.15",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7", "@types/node": "^18.11.18",
"@types/node": "^18.11.10", "@types/nodemailer": "^6.4.7",
"@types/nodemailer": "^6.4.6", "@types/passport-jwt": "^3.0.8",
"@types/passport-jwt": "^3.0.7", "@types/qrcode-svg": "^1.1.1",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.48.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.29.0", "eslint": "^8.31.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2", "newman": "^5.3.2",
"prettier": "^2.8.0", "prettier": "^2.8.2",
"prisma": "^4.7.1", "prisma": "^4.9.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"tsconfig-paths": "4.1.1", "tsconfig-paths": "4.1.2",
"typescript": "^4.9.3", "typescript": "^4.9.4",
"wait-on": "^6.0.1" "wait-on": "^7.0.1"
} }
} }

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "LoginToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "LoginToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
"totpSecret" TEXT
);
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,56 @@
/*
Warnings:
- Added the required column `category` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- 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,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
UPDATE config SET category = "internal" WHERE key = "SETUP_FINISHED";
UPDATE config SET category = "internal" WHERE key = "TOTP_SECRET";
UPDATE config SET category = "internal" WHERE key = "JWT_SECRET";
UPDATE config SET category = "general" WHERE key = "APP_URL";
UPDATE config SET category = "general" WHERE key = "SHOW_HOME_PAGE";
UPDATE config SET category = "share" WHERE key = "ALLOW_REGISTRATION";
UPDATE config SET category = "share" WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE config SET category = "share" WHERE key = "MAX_FILE_SIZE";
UPDATE config SET category = "email" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE config SET category = "email" WHERE key = "EMAIL_MESSAGE";
UPDATE config SET category = "email" WHERE key = "EMAIL_SUBJECT";
UPDATE config SET category = "email" WHERE key = "SMTP_HOST";
UPDATE config SET category = "email" WHERE key = "SMTP_PORT";
UPDATE config SET category = "email" WHERE key = "SMTP_EMAIL";
UPDATE config SET category = "email" WHERE key = "SMTP_USERNAME";
UPDATE config SET category = "email" WHERE key = "SMTP_PASSWORD";
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
);
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
DROP TABLE "RefreshToken";
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;

View File

@@ -0,0 +1,67 @@
/*
Warnings:
- Added the required column `order` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"creatorId" TEXT NOT NULL,
"shareId" TEXT,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ReverseShare_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL
);
INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "order") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", 0 FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_shareId_key" ON "ReverseShare"("shareId");
-- Custom migration
UPDATE Config SET `order` = 0 WHERE key = "JWT_SECRET";
UPDATE Config SET `order` = 0 WHERE key = "TOTP_SECRET";
UPDATE Config SET `order` = 1 WHERE key = "APP_URL";
UPDATE Config SET `order` = 2 WHERE key = "SHOW_HOME_PAGE";
UPDATE Config SET `order` = 3 WHERE key = "ALLOW_REGISTRATION";
UPDATE Config SET `order` = 4 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE Config SET `order` = 5 WHERE key = "MAX_SHARE_SIZE";
UPDATE Config SET `order` = 6, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE Config SET `order` = 7, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE";
UPDATE Config SET `order` = 8, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT";
UPDATE Config SET `order` = 12 WHERE key = "SMTP_HOST";
UPDATE Config SET `order` = 13 WHERE key = "SMTP_PORT";
UPDATE Config SET `order` = 14 WHERE key = "SMTP_EMAIL";
UPDATE Config SET `order` = 15 WHERE key = "SMTP_USERNAME";
UPDATE Config SET `order` = 16 WHERE key = "SMTP_PASSWORD";
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (11, "SMTP_ENABLED", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "boolean", IFNULL((SELECT value FROM Config WHERE key="ENABLE_SHARE_EMAIL_RECIPIENTS"), "false"), "smtp", 0, strftime('%s', 'now'));
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`, `locked`) VALUES (0, "SETUP_STATUS", "Status of the setup wizard", "string", IIF((SELECT value FROM Config WHERE key="SETUP_FINISHED") == "true", "FINISHED", "STARTED"), "internal", 0, strftime('%s', 'now'), 1);

View File

@@ -19,10 +19,17 @@ model User {
shares Share[] shares Share[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpSecret String?
} }
model RefreshToken { model RefreshToken {
token String @id @default(uuid()) id String @id @default(uuid())
token String @unique @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime
@@ -31,23 +38,55 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model LoginToken {
token String @id @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
used Boolean @default(false)
}
model Share { model Share {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
uploadLocked Boolean @default(false) uploadLocked Boolean @default(false)
isZipReady Boolean @default(false) isZipReady Boolean @default(false)
views Int @default(0) views Int @default(0)
expiration DateTime expiration DateTime
description String? description String?
removedReason String?
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShare ReverseShare?
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
security ShareSecurity? security ShareSecurity?
recipients ShareRecipient[] recipients ShareRecipient[]
files File[] files File[]
} }
model ReverseShare {
id String @id @default(uuid())
createdAt DateTime @default(now())
token String @unique @default(uuid())
shareExpiration DateTime
maxShareSize String
sendEmailNotification Boolean
used Boolean @default(false)
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model ShareRecipient { model ShareRecipient {
id String @id @default(uuid()) id String @id @default(uuid())
email String email String
@@ -85,7 +124,9 @@ model Config {
type String type String
value String value String
description String description String
category String
obscured Boolean @default(false) obscured Boolean @default(false)
secret Boolean @default(true) secret Boolean @default(true)
locked Boolean @default(false) locked Boolean @default(false)
order Int
} }

View File

@@ -3,93 +3,178 @@ import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [ const configVariables: Prisma.ConfigCreateInput[] = [
{ {
key: "SETUP_FINISHED", order: 0,
description: "Whether the setup has been finished", key: "SETUP_STATUS",
type: "boolean", description: "Status of the setup wizard",
value: "false", type: "string",
value: "STARTED", // STARTED, REGISTERED, FINISHED
category: "internal",
secret: false, secret: false,
locked: true, locked: true,
}, },
{ {
key: "APP_URL", order: 0,
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
secret: false,
},
{
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
secret: false,
},
{
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
secret: false,
},
{
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
secret: false,
},
{
key: "MAX_FILE_SIZE",
description: "Maximum file size in bytes",
type: "number",
value: "1000000000",
secret: false,
},
{
key: "JWT_SECRET", key: "JWT_SECRET",
description: "Long random string used to sign JWT tokens", description: "Long random string used to sign JWT tokens",
type: "string", type: "string",
value: crypto.randomBytes(256).toString("base64"), value: crypto.randomBytes(256).toString("base64"),
category: "internal",
locked: true, locked: true,
}, },
{ {
key: "ENABLE_EMAIL_RECIPIENTS", order: 0,
description: key: "TOTP_SECRET",
"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.", description: "A 16 byte random string used to generate TOTP secrets",
type: "boolean", type: "string",
value: "false", value: crypto.randomBytes(16).toString("base64"),
category: "internal",
locked: true,
},
{
order: 1,
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
category: "general",
secret: false, secret: false,
}, },
{ {
order: 2,
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
category: "general",
secret: false,
},
{
order: 3,
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
category: "share",
secret: false,
},
{
order: 4,
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
order: 5,
key: "MAX_SHARE_SIZE",
description: "Maximum share size in bytes",
type: "number",
value: "1073741824",
category: "share",
secret: false,
},
{
order: 6,
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
description:
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
type: "boolean",
value: "false",
category: "email",
secret: false,
},
{
order: 7,
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: 8,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{
order: 9,
key: "REVERSE_SHARE_EMAIL_MESSAGE",
description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
order: 10,
key: "REVERSE_SHARE_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string",
value: "Reverse share link used",
category: "email",
},
{
order: 11,
key: "SMTP_ENABLED",
description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "boolean",
value: "false",
category: "smtp",
secret: false,
},
{
order: 12,
key: "SMTP_HOST", key: "SMTP_HOST",
description: "Host of the SMTP server", description: "Host of the SMTP server",
type: "string", type: "string",
value: "", value: "",
category: "smtp",
}, },
{ {
order: 13,
key: "SMTP_PORT", key: "SMTP_PORT",
description: "Port of the SMTP server", description: "Port of the SMTP server",
type: "number", type: "number",
value: "", value: "0",
category: "smtp",
}, },
{ {
order: 14,
key: "SMTP_EMAIL", key: "SMTP_EMAIL",
description: "Email address which the emails get sent from", description: "Email address which the emails get sent from",
type: "string", type: "string",
value: "", value: "",
category: "smtp",
}, },
{ {
order: 15,
key: "SMTP_USERNAME", key: "SMTP_USERNAME",
description: "Username of the SMTP server", description: "Username of the SMTP server",
type: "string", type: "string",
value: "", value: "",
category: "smtp",
}, },
{ {
order: 16,
key: "SMTP_PASSWORD", key: "SMTP_PASSWORD",
description: "Password of the SMTP server", description: "Password of the SMTP server",
type: "string", type: "string",
value: "", value: "",
obscured: true, obscured: true,
category: "smtp",
}, },
]; ];

View File

@@ -1,19 +1,19 @@
import { HttpException, HttpStatus, Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { MulterModule } from "@nestjs/platform-express"; import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { Request } from "express";
import { ConfigModule } from "./config/config.module"; import { ConfigModule } from "./config/config.module";
import { ConfigService } from "./config/config.service";
import { EmailModule } from "./email/email.module"; import { EmailModule } from "./email/email.module";
import { FileModule } from "./file/file.module"; import { FileModule } from "./file/file.module";
import { JobsModule } from "./jobs/jobs.module";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
import { ShareModule } from "./share/share.module"; import { ShareModule } from "./share/share.module";
import { UserModule } from "./user/user.module"; import { UserModule } from "./user/user.module";
import { JobsModule } from "./jobs/jobs.module"; import { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
@Module({ @Module({
imports: [ imports: [
@@ -25,29 +25,19 @@ import { JobsModule } from "./jobs/jobs.module";
ConfigModule, ConfigModule,
JobsModule, JobsModule,
UserModule, UserModule,
MulterModule.registerAsync({
useFactory: (config: ConfigService) => ({
fileFilter: (req: Request, file, cb) => {
const MAX_FILE_SIZE = config.get("MAX_FILE_SIZE");
const requestFileSize = parseInt(req.headers["content-length"]);
const isValidFileSize = requestFileSize <= MAX_FILE_SIZE;
cb(
!isValidFileSize &&
new HttpException(
`File must be smaller than ${MAX_FILE_SIZE} bytes`,
HttpStatus.PAYLOAD_TOO_LARGE
),
isValidFileSize
);
},
}),
inject: [ConfigService],
}),
ThrottlerModule.forRoot({ ThrottlerModule.forRoot({
ttl: 60, ttl: 60,
limit: 100, limit: 100,
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ClamScanModule,
ReverseShareModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -5,53 +5,171 @@ import {
HttpCode, HttpCode,
Patch, Patch,
Post, Post,
Req,
Res,
UnauthorizedException,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service";
import { GetUser } from "./decorator/getUser.decorator"; import { GetUser } from "./decorator/getUser.decorator";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
import { JwtGuard } from "./guard/jwt.guard"; import { JwtGuard } from "./guard/jwt.guard";
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private authTotpService: AuthTotpService,
private config: ConfigService private config: ConfigService
) {} ) {}
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
async signUp(@Body() dto: AuthRegisterDTO) { async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
) {
if (!this.config.get("ALLOW_REGISTRATION")) if (!this.config.get("ALLOW_REGISTRATION"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
return this.authService.signUp(dto);
const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
return result;
} }
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@HttpCode(200) @HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) { async signIn(
return this.authService.signIn(dto); @Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
}
return result;
}
@Throttle(10, 5 * 60)
@Post("signIn/totp")
@HttpCode(200)
async signInTotp(
@Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
return new TokenDTO().from(result);
} }
@Patch("password") @Patch("password")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) { async updatePassword(
await this.authService.updatePassword(user, dto.oldPassword, dto.password); @GetUser() user: User,
@Res({ passthrough: true }) response: Response,
@Body() dto: UpdatePasswordDTO
) {
const result = await this.authService.updatePassword(
user,
dto.oldPassword,
dto.password
);
response = this.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result);
} }
@Post("token") @Post("token")
@HttpCode(200) @HttpCode(200)
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) { async refreshAccessToken(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken( const accessToken = await this.authService.refreshAccessToken(
body.refreshToken request.cookies.refresh_token
); );
return { accessToken }; response.cookie("access_token", accessToken);
return new TokenDTO().from({ accessToken });
}
@Post("signOut")
async signOut(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {
path: "/api/auth/token",
httpOnly: true,
maxAge: -1,
});
}
@Post("totp/enable")
@UseGuards(JwtGuard)
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
return this.authTotpService.enableTotp(user, body.password);
}
@Post("totp/verify")
@UseGuards(JwtGuard)
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
return this.authTotpService.verifyTotp(user, body.password, body.code);
}
@Post("totp/disable")
@UseGuards(JwtGuard)
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code);
}
private addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string
) {
if (accessToken) response.cookie("access_token", accessToken);
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
return response;
} }
} }

View File

@@ -2,12 +2,13 @@ import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy"; import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({ @Module({
imports: [JwtModule.register({})], imports: [JwtModule.register({})],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService], exports: [AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -23,6 +23,8 @@ export class AuthService {
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
const hash = await argon.hash(dto.password); const hash = await argon.hash(dto.password);
try { try {
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
@@ -30,12 +32,18 @@ export class AuthService {
email: dto.email, email: dto.email,
username: dto.username, username: dto.username,
password: hash, password: hash,
isAdmin: !this.config.get("SETUP_FINISHED"), isAdmin: isFirstUser,
}, },
}); });
const accessToken = await this.createAccessToken(user); if (isFirstUser) {
const refreshToken = await this.createRefreshToken(user.id); await this.config.changeSetupStatus("REGISTERED");
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} catch (e) { } catch (e) {
@@ -63,29 +71,46 @@ export class AuthService {
if (!user || !(await argon.verify(user.password, dto.password))) if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password"); throw new UnauthorizedException("Wrong email or password");
const accessToken = await this.createAccessToken(user); // TODO: Make all old loginTokens invalid when a new one is created
const refreshToken = await this.createRefreshToken(user.id); // Check if the user has TOTP enabled
if (user.totpVerified) {
const loginToken = await this.createLoginToken(user.id);
return { loginToken };
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
async updatePassword(user: User, oldPassword: string, newPassword: string) { async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (argon.verify(user.password, oldPassword)) if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
const hash = await argon.hash(newPassword); const hash = await argon.hash(newPassword);
this.prisma.user.update({ await this.prisma.refreshToken.deleteMany({
where: { userId: user.id },
});
await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { password: hash }, data: { password: hash },
}); });
return this.createRefreshToken(user.id);
} }
async createAccessToken(user: User) { async createAccessToken(user: User, refreshTokenId: string) {
return this.jwtService.sign( return this.jwtService.sign(
{ {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
refreshTokenId,
}, },
{ {
expiresIn: "15min", expiresIn: "15min",
@@ -94,6 +119,19 @@ export class AuthService {
); );
} }
async signOut(accessToken: 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;
});
}
async refreshAccessToken(refreshToken: string) { async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({ const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken }, where: { token: refreshToken },
@@ -103,16 +141,27 @@ export class AuthService {
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date()) if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
throw new UnauthorizedException(); throw new UnauthorizedException();
return this.createAccessToken(refreshTokenMetaData.user); return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
);
} }
async createRefreshToken(userId: string) { async createRefreshToken(userId: string) {
const refreshToken = ( const { id, token } = await this.prisma.refreshToken.create({
await this.prisma.refreshToken.create({ data: { userId, expiresAt: moment().add(3, "months").toDate() },
data: { userId, expiresAt: moment().add(3, "months").toDate() }, });
return { refreshTokenId: id, refreshToken: token };
}
async createLoginToken(userId: string) {
const loginToken = (
await this.prisma.loginToken.create({
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
}) })
).token; ).token;
return refreshToken; return loginToken;
} }
} }

View File

@@ -0,0 +1,230 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} 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";
@Injectable()
export class AuthTotpService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private authService: AuthService
) {}
async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({
where: {
token: dto.loginToken,
},
});
if (!token || token.userId != user.id || token.used)
throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired");
// Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
const expected = authenticator.generate(decryptedSecret);
if (dto.totp !== expected) {
throw new BadRequestException("Invalid code");
}
// Set the login token to used
await this.prisma.loginToken.update({
where: { token: token.token },
data: { used: true },
});
const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken(
user,
refreshTokenId
);
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");
// Check if we have a secret already
const { totpVerified } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpVerified: true },
});
if (totpVerified) {
throw new BadRequestException("TOTP is already enabled");
}
// 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,
"pingvin-share",
secret
);
await this.prisma.user.update({
where: { id: user.id },
data: {
totpEnabled: true,
totpSecret: encryptedSecret,
},
});
// TODO: Maybe we should generate the QR code on the client rather than the server?
const qrCode = new qrcode({
content: otpURL,
container: "svg-viewbox",
join: true,
}).svg();
return {
totpAuthUrl: otpURL,
totpSecret: secret,
qrCode:
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
};
}
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
async verifyTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not in progress");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: true,
},
});
return true;
}
async disableTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: false,
totpEnabled: false,
totpSecret: null,
},
});
return true;
}
}

View File

@@ -0,0 +1,21 @@
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;
@IsString()
totp: string;
@IsString()
loginToken: string;
}

View File

@@ -0,0 +1,4 @@
import { PickType } from "@nestjs/mapped-types";
import { UserDTO } from "src/user/dto/user.dto";
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()
refreshToken: string;
}

View File

@@ -0,0 +1,15 @@
import { Expose, plainToClass } from "class-transformer";
export class TokenDTO {
@Expose()
accessToken: string;
@Expose()
refreshToken: string;
from(partial: Partial<TokenDTO>) {
return plainToClass(TokenDTO, partial, {
excludeExtraneousValues: true,
});
}
}

View File

@@ -0,0 +1,8 @@
import { PickType } from "@nestjs/mapped-types";
import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) {
@IsString()
code: string;
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { ExtractJwt, Strategy } from "passport-jwt"; import { Request } from "express";
import { Strategy } from "passport-jwt";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
config.get("JWT_SECRET"); config.get("JWT_SECRET");
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: JwtStrategy.extractJWT,
secretOrKey: config.get("JWT_SECRET"), secretOrKey: config.get("JWT_SECRET"),
}); });
} }
private static extractJWT(req: Request) {
if (!req.cookies.access_token) return null;
return req.cookies.access_token;
}
async validate(payload: { sub: string }) { async validate(payload: { sub: string }) {
const user: User = await this.prisma.user.findUnique({ const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },

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

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

View File

@@ -1,22 +1,19 @@
import { import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto"; import { ConfigDTO } from "./dto/config.dto";
import { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto"; import UpdateConfigDTO from "./dto/updateConfig.dto";
@Controller("configs") @Controller("configs")
export class ConfigController { export class ConfigController {
constructor(private configService: ConfigService) {} constructor(
private configService: ConfigService,
private emailService: EmailService
) {}
@Get() @Get()
async list() { async list() {
@@ -31,17 +28,21 @@ export class ConfigController {
); );
} }
@Patch("admin/:key") @Patch("admin")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { async updateMany(@Body() data: UpdateConfigDTO[]) {
return new AdminConfigDTO().from( await this.configService.updateMany(data);
await this.configService.update(key, data.value)
);
} }
@Post("admin/finishSetup") @Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() { async finishSetup() {
return await this.configService.finishSetup(); return await this.configService.changeSetupStatus("FINISHED");
}
@Post("admin/testEmail")
@UseGuards(JwtGuard, AdministratorGuard)
async testEmail(@Body() { email }: TestEmailDTO) {
await this.emailService.sendTestMail(email);
} }
} }

View File

@@ -1,10 +1,12 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { EmailModule } from "src/email/email.module";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller"; import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
@Global() @Global()
@Module({ @Module({
imports: [EmailModule],
providers: [ providers: [
{ {
provide: "CONFIG_VARIABLES", provide: "CONFIG_VARIABLES",

View File

@@ -23,11 +23,13 @@ export class ConfigService {
if (configVariable.type == "number") return parseInt(configVariable.value); if (configVariable.type == "number") return parseInt(configVariable.value);
if (configVariable.type == "boolean") return configVariable.value == "true"; if (configVariable.type == "boolean") return configVariable.value == "true";
if (configVariable.type == "string") return configVariable.value; if (configVariable.type == "string" || configVariable.type == "text")
return configVariable.value;
} }
async listForAdmin() { async listForAdmin() {
return await this.prisma.config.findMany({ return await this.prisma.config.findMany({
orderBy: { order: "asc" },
where: { locked: { equals: false } }, where: { locked: { equals: false } },
}); });
} }
@@ -38,6 +40,14 @@ export class ConfigService {
}); });
} }
async updateMany(data: { key: string; value: string | number | boolean }[]) {
for (const variable of data) {
await this.update(variable.key, variable.value);
}
return data;
}
async update(key: string, value: string | number | boolean) { async update(key: string, value: string | number | boolean) {
const configVariable = await this.prisma.config.findUnique({ const configVariable = await this.prisma.config.findUnique({
where: { key }, where: { key },
@@ -46,10 +56,15 @@ export class ConfigService {
if (!configVariable || configVariable.locked) if (!configVariable || configVariable.locked)
throw new NotFoundException("Config variable not found"); throw new NotFoundException("Config variable not found");
if (typeof value != configVariable.type) if (
typeof value != configVariable.type &&
typeof value == "string" &&
configVariable.type != "text"
) {
throw new BadRequestException( throw new BadRequestException(
`Config variable must be of type ${configVariable.type}` `Config variable must be of type ${configVariable.type}`
); );
}
const updatedVariable = await this.prisma.config.update({ const updatedVariable = await this.prisma.config.update({
where: { key }, where: { key },
@@ -61,10 +76,10 @@ export class ConfigService {
return updatedVariable; return updatedVariable;
} }
async finishSetup() { async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
return await this.prisma.config.update({ return await this.prisma.config.update({
where: { key: "SETUP_FINISHED" }, where: { key: "SETUP_STATUS" },
data: { value: "true" }, data: { value: status },
}); });
} }
} }

View File

@@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO {
@Expose() @Expose()
obscured: boolean; obscured: boolean;
@Expose()
category: string;
from(partial: Partial<AdminConfigDTO>) { from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,

View File

@@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from "class-validator";
export class TestEmailDTO {
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@@ -1,6 +1,9 @@
import { IsNotEmpty, ValidateIf } from "class-validator"; import { IsNotEmpty, IsString, ValidateIf } from "class-validator";
class UpdateConfigDTO { class UpdateConfigDTO {
@IsString()
key: string;
@IsNotEmpty() @IsNotEmpty()
@ValidateIf((dto) => dto.value !== "") @ValidateIf((dto) => dto.value !== "")
value: string | number | boolean; value: string | number | boolean;

View File

@@ -7,9 +7,11 @@ import { ConfigService } from "src/config/config.service";
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
async sendMail(recipientEmail: string, shareId: string, creator: User) { getTransporter() {
// create reusable transporter object using the default SMTP transport if (!this.config.get("SMTP_ENABLED"))
const transporter = nodemailer.createTransport({ throw new InternalServerErrorException("SMTP is disabled");
return nodemailer.createTransport({
host: this.config.get("SMTP_HOST"), host: this.config.get("SMTP_HOST"),
port: parseInt(this.config.get("SMTP_PORT")), port: parseInt(this.config.get("SMTP_PORT")),
secure: parseInt(this.config.get("SMTP_PORT")) == 465, secure: parseInt(this.config.get("SMTP_PORT")) == 465,
@@ -18,17 +20,55 @@ export class EmailService {
pass: this.config.get("SMTP_PASSWORD"), pass: this.config.get("SMTP_PASSWORD"),
}, },
}); });
}
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"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
await transporter.sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: "Files shared with you", subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}\nShared securely with Pingvin Share 🐧`, text: this.config
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl),
}); });
} }
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
text: this.config
.get("REVERSE_SHARE_EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl),
});
}
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);
}
}
} }

View File

@@ -1,77 +1,49 @@
import { import {
Body,
Controller, Controller,
Get, Get,
Param, Param,
Post, Post,
Query,
Res, Res,
StreamableFile, StreamableFile,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express"; import { SkipThrottle } from "@nestjs/throttler";
import * as contentDisposition from "content-disposition"; import * as contentDisposition from "content-disposition";
import { Response } from "express"; import { Response } from "express";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { CreateShareGuard } from "src/share/guard/createShare.guard";
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
import { ShareDTO } from "src/share/dto/share.dto";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard"; import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
@Controller("shares/:shareId/files") @Controller("shares/:shareId/files")
export class FileController { export class FileController {
constructor(private fileService: FileService) {} constructor(private fileService: FileService) {}
@Post() @Post()
@UseGuards(JwtGuard, ShareOwnerGuard) @SkipThrottle()
@UseInterceptors( @UseGuards(CreateShareGuard, ShareOwnerGuard)
FileInterceptor("file", {
dest: "./data/uploads/_temp/",
})
)
async create( async create(
@UploadedFile() @Query() query: any,
file: Express.Multer.File,
@Body() body: string,
@Param("shareId") shareId: string @Param("shareId") shareId: string
) { ) {
// Fixes file names with special characters const { id, name, chunkIndex, totalChunks } = query;
file.originalname = Buffer.from(file.originalname, "latin1").toString(
"utf8" const data = body.toString().split(",")[1];
return await this.fileService.create(
data,
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
{ id, name },
shareId
); );
return new ShareDTO().from(await this.fileService.create(file, shareId));
}
@Get(":fileId/download")
@UseGuards(ShareSecurityGuard)
async getFileDownloadUrl(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip/download")
@UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
res.set({
"Content-Type": "application/zip",
});
return { url };
} }
@Get("zip") @Get("zip")
@UseGuards(FileDownloadGuard) @UseGuards(FileSecurityGuard)
async getZip( async getZip(
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string @Param("shareId") shareId: string
@@ -79,25 +51,32 @@ export class FileController {
const zip = this.fileService.getZip(shareId); const zip = this.fileService.getZip(shareId);
res.set({ res.set({
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`, "Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
}); });
return new StreamableFile(zip); return new StreamableFile(zip);
} }
@Get(":fileId") @Get(":fileId")
@UseGuards(FileDownloadGuard) @UseGuards(FileSecurityGuard)
async getFile( async getFile(
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string, @Param("shareId") shareId: string,
@Param("fileId") fileId: string @Param("fileId") fileId: string,
@Query("download") download = "true"
) { ) {
const file = await this.fileService.get(shareId, fileId); const file = await this.fileService.get(shareId, fileId);
res.set({
const headers = {
"Content-Type": file.metaData.mimeType, "Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size, "Content-Length": file.metaData.size,
"Content-Disposition": contentDisposition(file.metaData.name), };
});
if (download === "true") {
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
}
res.set(headers);
return new StreamableFile(file.file); return new StreamableFile(file.file);
} }

View File

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

View File

@@ -1,10 +1,12 @@
import { import {
BadRequestException, BadRequestException,
HttpException,
HttpStatus,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "crypto"; import * as crypto from "crypto";
import * as fs from "fs"; import * as fs from "fs";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
@@ -18,32 +20,88 @@ export class FileService {
private config: ConfigService private config: ConfigService
) {} ) {}
async create(file: Express.Multer.File, shareId: string) { async create(
data: string,
chunk: { index: number; total: number },
file: { id?: string; name: string },
shareId: string
) {
if (!file.id) file.id = crypto.randomUUID();
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id: shareId }, where: { id: shareId },
include: { files: true, reverseShare: true },
}); });
if (share.uploadLocked) if (share.uploadLocked)
throw new BadRequestException("Share is already completed"); throw new BadRequestException("Share is already completed");
const fileId = randomUUID(); let diskFileSize: number;
try {
diskFileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
).size;
} catch {
diskFileSize = 0;
}
await fs.promises.mkdir(`./data/uploads/shares/${shareId}`, { // If the sent chunk index and the expected chunk index doesn't match throw an error
recursive: true, const chunkSize = 10 * 1024 * 1024; // 10MB
}); const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
fs.promises.rename(
`./data/uploads/_temp/${file.filename}`, if (expectedChunkIndex != chunk.index)
`./data/uploads/shares/${shareId}/${fileId}` throw new BadRequestException({
message: "Unexpected chunk received",
error: "unexpected_chunk_index",
expectedChunkIndex,
});
const buffer = Buffer.from(data, "base64");
// Check if share size limit is exceeded
const fileSizeSum = share.files.reduce(
(n, { size }) => n + parseInt(size),
0
); );
return await this.prisma.file.create({ const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
data: {
id: fileId, if (
name: file.originalname, shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
size: file.size.toString(), (share.reverseShare?.maxShareSize &&
share: { connect: { id: shareId } }, shareSizeSum > parseInt(share.reverseShare.maxShareSize))
}, ) {
}); throw new HttpException(
"Max share size exceeded",
HttpStatus.PAYLOAD_TOO_LARGE
);
}
fs.appendFileSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
buffer
);
const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) {
fs.renameSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
`./data/uploads/shares/${shareId}/${file.id}`
);
const fileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}`
).size;
await this.prisma.file.create({
data: {
id: file.id,
name: file.name,
size: fileSize.toString(),
share: { connect: { id: shareId } },
},
});
}
return file;
} }
async get(shareId: string, fileId: string) { async get(shareId: string, fileId: string) {
@@ -77,38 +135,4 @@ export class FileService {
getZip(shareId: string) { getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
} }
getFileDownloadUrl(shareId: string, fileId: string) {
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
return `${this.config.get(
"APP_URL"
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
}
generateFileDownloadToken(shareId: string, fileId: string) {
if (fileId == "zip") fileId = undefined;
return this.jwtService.sign(
{
shareId,
fileId,
},
{
expiresIn: "10min",
secret: this.config.get("JWT_SECRET"),
}
);
}
verifyFileDownloadToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId;
} catch {
return false;
}
}
} }

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from "@nestjs/common";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class FileValidationPipe implements PipeTransform {
constructor(private config: ConfigService) {}
async transform(value: any, metadata: ArgumentMetadata) {
if (value.size > this.config.get("MAX_FILE_SIZE"))
throw new BadRequestException("File is ");
return value;
}
}

View File

@@ -38,18 +38,34 @@ export class JobsService {
@Cron("0 0 * * *") @Cron("0 0 * * *")
deleteTemporaryFiles() { deleteTemporaryFiles() {
const files = fs.readdirSync("./data/uploads/_temp"); let filesDeleted = 0;
for (const file of files) { const shareDirectories = fs
const stats = fs.statSync(`./data/uploads/_temp/${file}`); .readdirSync("./data/uploads/shares", { withFileTypes: true })
const isOlderThanOneDay = moment(stats.mtime) .filter((dirent) => dirent.isDirectory())
.add(1, "day") .map((dirent) => dirent.name);
.isBefore(moment());
if (isOlderThanOneDay) fs.rmSync(`./data/uploads/_temp/${file}`); for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) {
const stats = fs.statSync(
`./data/uploads/shares/${shareDirectory}/${file}`
);
const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day")
.isBefore(moment());
if (isOlderThanOneDay) {
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
filesDeleted++;
}
}
} }
console.log(`job: deleted ${files.length} temporary files`); console.log(`job: deleted ${filesDeleted} temporary files`);
} }
@Cron("0 * * * *") @Cron("0 * * * *")

View File

@@ -1,6 +1,8 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core"; import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
@@ -9,6 +11,8 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });

View File

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

View File

@@ -0,0 +1,18 @@
import { Expose, plainToClass } from "class-transformer";
export class ReverseShareDTO {
@Expose()
id: string;
@Expose()
maxShareSize: string;
@Expose()
shareExpiration: Date;
from(partial: Partial<ReverseShareDTO>) {
return plainToClass(ReverseShareDTO, partial, {
excludeExtraneousValues: true,
});
}
}

View File

@@ -0,0 +1,26 @@
import { OmitType } from "@nestjs/mapped-types";
import { Expose, plainToClass, Type } from "class-transformer";
import { MyShareDTO } from "src/share/dto/myShare.dto";
import { ReverseShareDTO } from "./reverseShare.dto";
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
"shareExpiration",
] as const) {
@Expose()
shareExpiration: Date;
@Expose()
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
share: Omit<
MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword"
>;
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
return partial.map((part) =>
plainToClass(ReverseShareTokenWithShare, part, {
excludeExtraneousValues: true,
})
);
}
}

View File

@@ -0,0 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ReverseShareOwnerGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const { reverseShareId } = request.params;
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { id: reverseShareId },
});
if (!reverseShare) return false;
return reverseShare.creatorId == (request.user as User).id;
}
}

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
import { ReverseShareDTO } from "./dto/reverseShare.dto";
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare";
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
import { ReverseShareService } from "./reverseShare.service";
@Controller("reverseShares")
export class ReverseShareController {
constructor(
private reverseShareService: ReverseShareService,
private config: ConfigService
) {}
@Post()
@UseGuards(JwtGuard)
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
const token = await this.reverseShareService.create(body, user.id);
const link = `${this.config.get("APP_URL")}/upload/${token}`;
return { token, link };
}
@Throttle(20, 60)
@Get(":reverseShareToken")
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
const isValid = await this.reverseShareService.isValid(reverseShareToken);
if (!isValid) throw new NotFoundException("Reverse share token not found");
return new ReverseShareDTO().from(
await this.reverseShareService.getByToken(reverseShareToken)
);
}
@Get()
@UseGuards(JwtGuard)
async getAllByUser(@GetUser() user: User) {
return new ReverseShareTokenWithShare().fromList(
await this.reverseShareService.getAllByUser(user.id)
);
}
@Delete(":reverseShareId")
@UseGuards(JwtGuard, ReverseShareOwnerGuard)
async remove(@Param("reverseShareId") id: string) {
await this.reverseShareService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module";
import { ReverseShareController } from "./reverseShare.controller";
import { ReverseShareService } from "./reverseShare.service";
@Module({
imports: [forwardRef(() => FileModule)],
controllers: [ReverseShareController],
providers: [ReverseShareService],
exports: [ReverseShareService],
})
export class ReverseShareModule {}

View File

@@ -0,0 +1,94 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
@Injectable()
export class ReverseShareService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private fileService: FileService
) {}
async create(data: CreateReverseShareDTO, creatorId: string) {
// Parse date string to date
const expirationDate = moment()
.add(
data.shareExpiration.split("-")[0],
data.shareExpiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
if (globalMaxShareSize < data.maxShareSize)
throw new BadRequestException(
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
);
const reverseShare = await this.prisma.reverseShare.create({
data: {
shareExpiration: expirationDate,
maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification,
creatorId,
},
});
return reverseShare.token;
}
async getByToken(reverseShareToken: string) {
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { token: reverseShareToken },
});
return reverseShare;
}
async getAllByUser(userId: string) {
const reverseShares = await this.prisma.reverseShare.findMany({
where: {
creatorId: userId,
shareExpiration: { gt: new Date() },
},
orderBy: {
shareExpiration: "desc",
},
include: { share: { include: { creator: true } } },
});
return reverseShares;
}
async isValid(reverseShareToken: string) {
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { token: reverseShareToken },
});
if (!reverseShare) return false;
const isExpired = new Date() > reverseShare.shareExpiration;
const isUsed = reverseShare.used;
return !(isExpired || isUsed);
}
async remove(id: string) {
const share = await this.prisma.share.findFirst({
where: { reverseShare: { id } },
});
if (share) {
await this.prisma.share.delete({ where: { id: share.id } });
await this.fileService.deleteAllFiles(share.id);
} else {
await this.prisma.reverseShare.delete({ where: { id } });
}
}
}

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express"; import { Request } from "express";
import * as moment from "moment"; import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
@Injectable() @Injectable()
export class ShareSecurityGuard implements CanActivate { export class ShareSecurityGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector,
private shareService: ShareService, private shareService: ShareService,
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest(); const request: Request = context.switchToHttp().getRequest();
const shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call( const shareId = Object.prototype.hasOwnProperty.call(
request.params, request.params,
"shareId" "shareId"
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
? request.params.shareId ? request.params.shareId
: request.params.id; : request.params.id;
const shareToken = request.cookies[`share_${shareId}_token`];
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id: shareId }, where: { id: shareId },
include: { security: true }, include: { security: true },
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
if ( if (
!share || !share ||
(moment().isAfter(share.expiration) && (moment().isAfter(share.expiration) &&
moment(share.expiration).unix() !== 0) !moment(share.expiration).isSame(0))
) )
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");

View File

@@ -1,7 +1,6 @@
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
) )
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
if (share.security?.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
return true; return true;
} }
} }

View File

@@ -6,10 +6,13 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Req,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator"; import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@@ -17,6 +20,7 @@ import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto"; import { ShareDTO } from "./dto/share.dto";
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto"; import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto"; import { SharePasswordDto } from "./dto/sharePassword.dto";
import { CreateShareGuard } from "./guard/createShare.guard";
import { ShareOwnerGuard } from "./guard/shareOwner.guard"; import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
@@ -46,9 +50,16 @@ export class ShareController {
} }
@Post() @Post()
@UseGuards(JwtGuard) @UseGuards(CreateShareGuard)
async create(@Body() body: CreateShareDTO, @GetUser() user: User) { async create(
return new ShareDTO().from(await this.shareService.create(body, user)); @Body() body: CreateShareDTO,
@Req() request: Request,
@GetUser() user: User
) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.create(body, user, reverse_share_token)
);
} }
@Delete(":id") @Delete(":id")
@@ -59,21 +70,35 @@ export class ShareController {
@Post(":id/complete") @Post(":id/complete")
@HttpCode(202) @HttpCode(202)
@UseGuards(JwtGuard, ShareOwnerGuard) @UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string) { async complete(@Param("id") id: string, @Req() request: Request) {
return new ShareDTO().from(await this.shareService.complete(id)); const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token)
);
} }
@Throttle(10, 60)
@Get("isShareIdAvailable/:id") @Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) { async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id); return this.shareService.isShareIdAvailable(id);
} }
@HttpCode(200) @HttpCode(200)
@Throttle(10, 5 * 60) @Throttle(20, 5 * 60)
@UseGuards(ShareTokenSecurity) @UseGuards(ShareTokenSecurity)
@Post(":id/token") @Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) { async getShareToken(
return this.shareService.getShareToken(id, body.password); @Param("id") id: string,
@Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto
) {
const token = await this.shareService.getShareToken(id, body.password);
response.cookie(`share_${id}_token`, token, {
path: "/",
httpOnly: true,
});
return { token };
} }
} }

View File

@@ -1,12 +1,20 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { ClamScanModule } from "src/clamscan/clamscan.module";
import { EmailModule } from "src/email/email.module"; import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module"; import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareController } from "./share.controller"; import { ShareController } from "./share.controller";
import { ShareService } from "./share.service"; import { ShareService } from "./share.service";
@Module({ @Module({
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)], imports: [
JwtModule.register({}),
EmailModule,
ClamScanModule,
ReverseShareModule,
forwardRef(() => FileModule),
],
controllers: [ShareController], controllers: [ShareController],
providers: [ShareService], providers: [ShareService],
exports: [ShareService], exports: [ShareService],

View File

@@ -10,10 +10,12 @@ import * as archiver from "archiver";
import * as argon from "argon2"; import * as argon from "argon2";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { ClamScanService } from "src/clamscan/clamscan.service";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable() @Injectable()
@@ -23,10 +25,12 @@ export class ShareService {
private fileService: FileService, private fileService: FileService,
private emailService: EmailService, private emailService: EmailService,
private config: ConfigService, 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) if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use"); throw new BadRequestException("Share id already in use");
@@ -37,26 +41,36 @@ export class ShareService {
share.security.password = await argon.hash(share.security.password); share.security.password = await argon.hash(share.security.password);
} }
// We have to add an exception for "never" (since moment won't like that)
let expirationDate: Date; let expirationDate: Date;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
// Throw error if expiration date is now // If share is created by a reverse share token override the expiration date
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) if (reverseShareToken) {
throw new BadRequestException("Invalid expiration date"); const { shareExpiration } = await this.reverseShareService.getByToken(
reverseShareToken
);
expirationDate = shareExpiration;
} else { } else {
expirationDate = moment(0).toDate(); // We have to add an exception for "never" (since moment won't like that)
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
} else {
expirationDate = moment(0).toDate();
}
} }
return await this.prisma.share.create({ fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
recursive: true,
});
const shareTuple = await this.prisma.share.create({
data: { data: {
...share, ...share,
expiration: expirationDate, expiration: expirationDate,
@@ -69,6 +83,18 @@ export class ShareService {
}, },
}, },
}); });
if (reverseShareToken) {
// Assign share to reverse share token
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: {
shareId: share.id,
},
});
}
return shareTuple;
} }
async createZip(shareId: string) { async createZip(shareId: string) {
@@ -90,10 +116,15 @@ export class ShareService {
await archive.finalize(); await archive.finalize();
} }
async complete(id: string) { async complete(id: string, reverseShareToken?: string) {
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id }, where: { id },
include: { files: true, recipients: true, creator: true }, include: {
files: true,
recipients: true,
creator: true,
reverseShare: { include: { creator: true } },
},
}); });
if (await this.isShareCompleted(id)) if (await this.isShareCompleted(id))
@@ -112,13 +143,34 @@ export class ShareService {
// Send email for each recepient // Send email for each recepient
for (const recepient of share.recipients) { for (const recepient of share.recipients) {
await this.emailService.sendMail( await this.emailService.sendMailToShareRecepients(
recepient.email, recepient.email,
share.id, share.id,
share.creator share.creator
); );
} }
if (
share.reverseShare &&
this.config.get("SMTP_ENABLED") &&
share.reverseShare.sendEmailNotification
) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id
);
}
// Check if any file is malicious with ClamAV
this.clamScanService.checkAndRemove(share.id);
if (reverseShareToken) {
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: { used: true },
});
}
return await this.prisma.share.update({ return await this.prisma.share.update({
where: { id }, where: { id },
data: { uploadLocked: true }, data: { uploadLocked: true },
@@ -152,19 +204,25 @@ export class ShareService {
return sharesWithEmailRecipients; return sharesWithEmailRecipients;
} }
async get(id: string) { async get(id: string): Promise<any> {
const share: any = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id }, where: { id },
include: { include: {
files: true, files: true,
creator: true, creator: true,
security: true,
}, },
}); });
if (share.removedReason)
throw new NotFoundException(share.removedReason, "share_removed");
if (!share || !share.uploadLocked) if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
return {
return share; ...share,
hasPassword: share.security?.password ? true : false,
};
} }
async getMetaData(id: string) { async getMetaData(id: string) {
@@ -218,12 +276,20 @@ export class ShareService {
if ( if (
share?.security?.password && share?.security?.password &&
!(await argon.verify(share.security.password, password)) !(await argon.verify(share.security.password, password))
) ) {
throw new ForbiddenException("Wrong password"); throw new ForbiddenException("Wrong password");
}
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
}
const token = await this.generateShareToken(shareId); const token = await this.generateShareToken(shareId);
await this.increaseViewCount(share); await this.increaseViewCount(share);
return { token }; return token;
} }
async generateShareToken(shareId: string) { async generateShareToken(shareId: string) {

View File

@@ -22,6 +22,9 @@ export class UserDTO {
@Expose() @Expose()
isAdmin: boolean; isAdmin: boolean;
@Expose()
totpVerified: boolean;
from(partial: Partial<UserDTO>) { from(partial: Partial<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
} }

View File

@@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a", "_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
"name": "Pingvin Share Testing", "name": "Pingvin Share Testing",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "17822132" "_exporter_id": "17822132"
@@ -18,12 +18,12 @@
"exec": [ "exec": [
"if(pm.response.to.have.status(201)){", "if(pm.response.to.have.status(201)){",
" const token = pm.response.json()[\"accessToken\"]", " const token = pm.response.json()[\"accessToken\"]",
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
"",
" // Get user id", " // Get user id",
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));", " const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
" const userId = jwtPayload[\"sub\"]", " const userId = jwtPayload[\"sub\"]",
" pm.collectionVariables.set(\"USER_ID\", userId)", " pm.collectionVariables.set(\"USER_ID\", userId)",
"",
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
"}", "}",
"" ""
], ],
@@ -80,6 +80,7 @@
" pm.expect(responseBody).to.have.property(\"accessToken\")", " pm.expect(responseBody).to.have.property(\"accessToken\")",
" pm.expect(responseBody).to.have.property(\"refreshToken\")", " pm.expect(responseBody).to.have.property(\"refreshToken\")",
"});", "});",
"",
"" ""
], ],
"type": "text/javascript" "type": "text/javascript"
@@ -97,7 +98,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}", "raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@@ -477,28 +478,34 @@
"pm.test(\"Response body correct\", () => {", "pm.test(\"Response body correct\", () => {",
" const responseBody = pm.response.json();", " const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"id\")", " pm.expect(responseBody).to.have.property(\"id\")",
" pm.expect(Object.keys(responseBody).length).be.equal(1)", " pm.expect(responseBody.name).to.be.equal(\"test-file.txt\")",
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
"});" "});"
], ],
"type": "text/javascript" "type": "text/javascript"
} }
} }
], ],
"protocolProfileBehavior": {
"disabledSystemHeaders": {
"content-type": true
}
},
"request": { "request": {
"method": "POST", "method": "POST",
"header": [], "header": [
{
"key": "Content-Type",
"value": "application/octet-stream",
"type": "text"
}
],
"body": { "body": {
"mode": "formdata", "mode": "raw",
"formdata": [ "raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
}
]
}, },
"url": { "url": {
"raw": "{{API_URL}}/shares/:shareId/files", "raw": "{{API_URL}}/shares/:shareId/files?name=test-file.txt&chunkIndex=0&totalChunks=1",
"host": [ "host": [
"{{API_URL}}" "{{API_URL}}"
], ],
@@ -507,6 +514,20 @@
":shareId", ":shareId",
"files" "files"
], ],
"query": [
{
"key": "name",
"value": "test-file.txt"
},
{
"key": "chunkIndex",
"value": "0"
},
{
"key": "totalChunks",
"value": "1"
}
],
"variable": [ "variable": [
{ {
"key": "shareId", "key": "shareId",
@@ -530,29 +551,34 @@
"", "",
"pm.test(\"Response body correct\", () => {", "pm.test(\"Response body correct\", () => {",
" const responseBody = pm.response.json();", " const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"id\")", " pm.expect(responseBody.name).to.be.equal(\"test-file2.txt\")",
" pm.expect(Object.keys(responseBody).length).be.equal(1)", " pm.expect(Object.keys(responseBody).length).be.equal(2)",
"});" "});"
], ],
"type": "text/javascript" "type": "text/javascript"
} }
} }
], ],
"protocolProfileBehavior": {
"disabledSystemHeaders": {
"content-type": true
}
},
"request": { "request": {
"method": "POST", "method": "POST",
"header": [], "header": [
{
"key": "Content-Type",
"value": "application/octet-stream",
"type": "text"
}
],
"body": { "body": {
"mode": "formdata", "mode": "raw",
"formdata": [ "raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
}
]
}, },
"url": { "url": {
"raw": "{{API_URL}}/shares/:shareId/files", "raw": "{{API_URL}}/shares/:shareId/files?name=test-file2.txt&chunkIndex=0&totalChunks=1",
"host": [ "host": [
"{{API_URL}}" "{{API_URL}}"
], ],
@@ -561,6 +587,20 @@
":shareId", ":shareId",
"files" "files"
], ],
"query": [
{
"key": "name",
"value": "test-file2.txt"
},
{
"key": "chunkIndex",
"value": "0"
},
{
"key": "totalChunks",
"value": "1"
}
],
"variable": [ "variable": [
{ {
"key": "shareId", "key": "shareId",
@@ -1556,23 +1596,13 @@
] ]
} }
], ],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{USER_AUTH_TOKEN}}",
"type": "string"
}
]
},
"event": [ "event": [
{ {
"listen": "prerequest", "listen": "prerequest",
"script": { "script": {
"type": "text/javascript", "type": "text/javascript",
"exec": [ "exec": [
"" "pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
] ]
} }
}, },

View File

@@ -1 +0,0 @@
This is a test filed used for uploading in the system test.

7
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,7 @@
version: '3.8'
services:
clamav:
restart: unless-stopped
ports:
- 3310:3310
image: clamav/clamav

View File

@@ -6,4 +6,13 @@ services:
ports: ports:
- 3000:3000 - 3000:3000
volumes: 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

View File

@@ -1,8 +1,14 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const { version } = require('./package.json');
const withPWA = require("next-pwa")({ const withPWA = require("next-pwa")({
dest: "public", dest: "public",
disable: process.env.NODE_ENV == "development", disable: process.env.NODE_ENV == "development",
}); });
module.exports = withPWA({ output: "standalone" }); module.exports = withPWA({
output: "standalone", env: {
VERSION: version,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share-frontend",
"version": "0.0.1", "version": "0.9.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -11,19 +11,20 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@mantine/core": "^5.9.2", "@mantine/core": "^5.10.0",
"@mantine/dropzone": "^5.9.2", "@mantine/dropzone": "^5.10.0",
"@mantine/form": "^5.9.2", "@mantine/form": "^5.10.0",
"@mantine/hooks": "^5.9.2", "@mantine/hooks": "^5.10.0",
"@mantine/modals": "^5.9.2", "@mantine/modals": "^5.10.0",
"@mantine/next": "^5.9.2", "@mantine/next": "^5.10.0",
"@mantine/notifications": "^5.9.2", "@mantine/notifications": "^5.10.0",
"axios": "^1.2.0", "axios": "^1.2.2",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.11.1", "jose": "^4.11.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^13.0.6", "next": "^13.1.2",
"next-cookies": "^2.0.3", "next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5", "next-http-proxy-middleware": "^1.2.5",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
@@ -34,15 +35,16 @@
"yup": "^0.32.11" "yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.11.10", "@types/mime-types": "^2.1.1",
"@types/node": "18.11.18",
"@types/react": "18.0.26", "@types/react": "18.0.26",
"@types/react-dom": "18.0.9", "@types/react-dom": "18.0.10",
"axios": "^1.2.0", "axios": "^1.2.2",
"eslint": "8.29.0", "eslint": "8.31.0",
"eslint-config-next": "^13.0.6", "eslint-config-next": "^13.1.2",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.6.0",
"prettier": "^2.8.0", "prettier": "^2.8.2",
"tar": "^6.1.12", "tar": "^6.1.13",
"typescript": "^4.9.3" "typescript": "^4.9.4"
} }
} }

View File

@@ -0,0 +1,67 @@
import {
Box,
Center,
ColorScheme,
SegmentedControl,
Stack,
useMantineColorScheme,
} from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { useState } from "react";
import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb";
import usePreferences from "../../hooks/usePreferences";
const ThemeSwitcher = () => {
const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState(
preferences.get("colorScheme")
);
const { toggleColorScheme } = useMantineColorScheme();
const systemColorScheme = useColorScheme();
return (
<Stack>
<SegmentedControl
value={colorScheme}
onChange={(value) => {
preferences.set("colorScheme", value);
setColorScheme(value);
toggleColorScheme(
value == "system" ? systemColorScheme : (value as ColorScheme)
);
}}
data={[
{
label: (
<Center>
<TbMoon size={16} />
<Box ml={10}>Dark</Box>
</Center>
),
value: "dark",
},
{
label: (
<Center>
<TbSun size={16} />
<Box ml={10}>Light</Box>
</Center>
),
value: "light",
},
{
label: (
<Center>
<TbDeviceLaptop size={16} />
<Box ml={10}>System</Box>
</Center>
),
value: "system",
},
]}
/>
</Stack>
);
};
export default ThemeSwitcher;

View File

@@ -0,0 +1,128 @@
import {
Button,
Center,
Col,
Grid,
Image,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
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";
const showEnableTotpModal = (
modals: ModalsContextProps,
refreshUser: () => {},
options: {
qrCode: string;
secret: string;
password: string;
}
) => {
return modals.openModal({
title: <Title order={4}>Enable TOTP</Title>,
children: (
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
),
});
};
const CreateEnableTotpModal = ({
options,
refreshUser,
}: {
options: {
qrCode: string;
secret: string;
password: string;
};
refreshUser: () => {};
}) => {
const modals = useModals();
const validationSchema = yup.object().shape({
code: yup
.string()
.min(6)
.max(6)
.required()
.matches(/^[0-9]+$/, { message: "Code must be a number" }),
});
const form = useForm({
initialValues: {
code: "",
},
validate: yupResolver(validationSchema),
});
return (
<div>
<Center>
<Stack>
<Text>Step 1: Add your authenticator</Text>
<Image src={options.qrCode} alt="QR Code" />
<Center>
<span>OR</span>
</Center>
<Tooltip label="Click to copy">
<Button
onClick={() => {
navigator.clipboard.writeText(options.secret);
toast.success("Copied to clipboard");
}}
>
{options.secret}
</Button>
</Tooltip>
<Center>
<Text fz="xs">Enter manually</Text>
</Center>
<Text>Step 2: Validate your code</Text>
<form
onSubmit={form.onSubmit((values) => {
authService
.verifyTOTP(values.code, options.password)
.then(() => {
toast.success("Successfully enabled TOTP");
modals.closeAll();
refreshUser();
})
.catch(toast.axiosError);
})}
>
<Grid align="flex-end">
<Col xs={9}>
<TextInput
variant="filled"
label="Code"
placeholder="******"
{...form.getInputProps("code")}
/>
</Col>
<Col xs={3}>
<Button variant="outline" type="submit">
Verify
</Button>
</Col>
</Grid>
</form>
</Stack>
</Center>
</div>
);
};
export default showEnableTotpModal;

View File

@@ -1,8 +1,12 @@
import { Stack, TextInput } from "@mantine/core"; import { Stack, TextInput } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => { const showShareLinkModal = (
const link = `${window.location.origin}/share/${shareId}`; modals: ModalsContextProps,
shareId: string,
appUrl: string
) => {
const link = `${appUrl}/share/${shareId}`;
return modals.openModal({ return modals.openModal({
title: "Share link", title: "Share link",
children: ( children: (

View File

@@ -1,106 +0,0 @@
import {
ActionIcon,
Box,
Code,
Group,
Skeleton,
Table,
Text,
} from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbEdit, TbLock } from "react-icons/tb";
import configService from "../../services/config.service";
import { AdminConfig as AdminConfigType } from "../../types/config.type";
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
const AdminConfigTable = () => {
const modals = useModals();
const [isLoading, setIsLoading] = useState(false);
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
const getConfigVariables = async () => {
await configService.listForAdmin().then((configVariables) => {
setConfigVariables(configVariables);
});
};
useEffect(() => {
setIsLoading(true);
getConfigVariables().then(() => setIsLoading(false));
}, []);
const skeletonRows = [...Array(9)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={18} width={80} mb="sm" />
<Skeleton height={30} />
</td>
<td>
<Skeleton height={18} />
</td>
<td>
<Group position="right">
<Skeleton height={25} width={25} />
</Group>
</td>
</tr>
));
return (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: configVariables.map((configVariable) => (
<tr key={configVariable.key}>
<td style={{ maxWidth: "200px" }}>
<Code>{configVariable.key}</Code>{" "}
{configVariable.secret && <TbLock />} <br />
<Text size="xs" color="dimmed">
{configVariable.description}
</Text>
</td>
<td>
{configVariable.obscured
? "•".repeat(configVariable.value.length)
: configVariable.value}
</td>
<td>
<Group position="right">
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={() =>
showUpdateConfigVariableModal(
modals,
configVariable,
getConfigVariables
)
}
>
<TbEdit />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
);
};
export default AdminConfigTable;

View File

@@ -0,0 +1,76 @@
import {
NumberInput,
PasswordInput,
Stack,
Switch,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
const AdminConfigInput = ({
configVariable,
updateConfigVariable,
}: {
configVariable: AdminConfig;
updateConfigVariable: (variable: UpdateConfig) => void;
}) => {
const form = useForm({
initialValues: {
stringValue: configVariable.value,
textValue: configVariable.value,
numberValue: parseInt(configVariable.value),
booleanValue: configVariable.value == "true",
},
});
const onValueChange = (configVariable: AdminConfig, value: any) => {
form.setFieldValue(`${configVariable.type}Value`, value);
updateConfigVariable({ key: configVariable.key, value: value });
};
return (
<Stack align="end">
{configVariable.type == "string" &&
(configVariable.obscured ? (
<PasswordInput
style={{ width: "100%" }}
{...form.getInputProps("stringValue")}
onChange={(e) => onValueChange(configVariable, e.target.value)}
/>
) : (
<TextInput
style={{ width: "100%" }}
{...form.getInputProps("stringValue")}
onChange={(e) => onValueChange(configVariable, e.target.value)}
/>
))}
{configVariable.type == "text" && (
<Textarea
style={{ width: "100%" }}
autosize
{...form.getInputProps("textValue")}
onChange={(e) => onValueChange(configVariable, e.target.value)}
/>
)}
{configVariable.type == "number" && (
<NumberInput
{...form.getInputProps("numberValue")}
onChange={(number) => onValueChange(configVariable, number)}
/>
)}
{configVariable.type == "boolean" && (
<>
<Switch
{...form.getInputProps("booleanValue", { type: "checkbox" })}
onChange={(e) => onValueChange(configVariable, e.target.checked)}
/>
</>
)}
</Stack>
);
};
export default AdminConfigInput;

View File

@@ -0,0 +1,144 @@
import {
Box,
Button,
Group,
Paper,
Space,
Stack,
Text,
Title,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useEffect, useState } from "react";
import useConfig from "../../../hooks/config.hook";
import configService from "../../../services/config.service";
import {
AdminConfigGroupedByCategory,
UpdateConfig,
} from "../../../types/config.type";
import {
capitalizeFirstLetter,
configVariableToFriendlyName,
} from "../../../utils/string.util";
import toast from "../../../utils/toast.util";
import AdminConfigInput from "./AdminConfigInput";
import TestEmailButton from "./TestEmailButton";
const AdminConfigTable = () => {
const config = useConfig();
const isMobile = useMediaQuery("(max-width: 560px)");
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key
);
if (index > -1) {
updatedConfigVariables[index] = configVariable;
} else {
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
}
};
const [configVariablesByCategory, setCofigVariablesByCategory] =
useState<AdminConfigGroupedByCategory>({});
const getConfigVariables = async () => {
await configService.listForAdmin().then((configVariables) => {
const configVariablesByCategory = configVariables.reduce(
(categories: any, item) => {
const category = categories[item.category] || [];
category.push(item);
categories[item.category] = category;
return categories;
},
{}
);
setCofigVariablesByCategory(configVariablesByCategory);
});
};
const saveConfigVariables = async () => {
if (config.get("SETUP_STATUS") == "REGISTERED") {
await configService
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
window.location.reload();
})
.catch(toast.axiosError);
} else {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
}
};
useEffect(() => {
getConfigVariables();
}, []);
return (
<Box mb="lg">
{Object.entries(configVariablesByCategory).map(
([category, configVariables]) => {
return (
<Paper key={category} withBorder p="lg" mb="xl">
<Title mb="xs" order={3}>
{capitalizeFirstLetter(category)}
</Title>
{configVariables.map((configVariable) => (
<>
<Group position="apart">
<Stack
style={{ maxWidth: isMobile ? "100%" : "40%" }}
spacing={0}
>
<Title order={6}>
{configVariableToFriendlyName(configVariable.key)}
</Title>
<Text color="dimmed" size="sm" mb="xs">
{configVariable.description}
</Text>
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
<AdminConfigInput
key={configVariable.key}
updateConfigVariable={updateConfigVariable}
configVariable={configVariable}
/>
</Box>
</Group>
<Space h="lg" />
</>
))}
{category == "smtp" && (
<Group position="right">
<TestEmailButton
configVariablesChanged={updatedConfigVariables.length != 0}
saveConfigVariables={saveConfigVariables}
/>
</Group>
)}
</Paper>
);
}
)}
<Group position="right">
<Button onClick={saveConfigVariables}>Save</Button>
</Group>
</Box>
);
};
export default AdminConfigTable;

View File

@@ -0,0 +1,72 @@
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 = ({
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={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>
);
};
export default TestEmailButton;

View File

@@ -1,100 +0,0 @@
import {
Button,
Code,
NumberInput,
PasswordInput,
Select,
Space,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import configService from "../../services/config.service";
import { AdminConfig } from "../../types/config.type";
import toast from "../../utils/toast.util";
const showUpdateConfigVariableModal = (
modals: ModalsContextProps,
configVariable: AdminConfig,
getConfigVariables: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update configuration variable</Title>,
children: (
<Body
configVariable={configVariable}
getConfigVariables={getConfigVariables}
/>
),
});
};
const Body = ({
configVariable,
getConfigVariables,
}: {
configVariable: AdminConfig;
getConfigVariables: () => void;
}) => {
const modals = useModals();
const form = useForm({
initialValues: {
stringValue: configVariable.value,
numberValue: parseInt(configVariable.value),
booleanValue: configVariable.value,
},
});
return (
<Stack align="stretch">
<Text>
Set <Code>{configVariable.key}</Code> to
</Text>
{configVariable.type == "string" &&
(configVariable.obscured ? (
<PasswordInput label="Value" {...form.getInputProps("stringValue")} />
) : (
<TextInput label="Value" {...form.getInputProps("stringValue")} />
))}
{configVariable.type == "number" && (
<NumberInput label="Value" {...form.getInputProps("numberValue")} />
)}
{configVariable.type == "boolean" && (
<Select
data={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
{...form.getInputProps("booleanValue")}
/>
)}
<Space />
<Button
onClick={async () => {
const value =
configVariable.type == "string"
? form.values.stringValue
: configVariable.type == "number"
? form.values.numberValue
: form.values.booleanValue == "true";
await configService
.update(configVariable.key, value)
.then(() => {
getConfigVariables();
modals.closeAll();
})
.catch(toast.axiosError);
}}
>
Save
</Button>
</Stack>
);
};
export default showUpdateConfigVariableModal;

View File

@@ -9,7 +9,10 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { showNotification } from "@mantine/notifications";
import Link from "next/link"; import Link from "next/link";
import React from "react";
import { TbInfoCircle } from "react-icons/tb";
import * as yup from "yup"; import * as yup from "yup";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
@@ -17,16 +20,24 @@ import toast from "../../utils/toast.util";
const SignInForm = () => { const SignInForm = () => {
const config = useConfig(); const config = useConfig();
const [showTotp, setShowTotp] = React.useState(false);
const [loginToken, setLoginToken] = React.useState("");
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(), emailOrUsername: yup.string().required(),
password: yup.string().min(8).required(), password: yup.string().min(8).required(),
totp: yup.string().when("totpRequired", {
is: true,
then: yup.string().min(6).max(6).required(),
otherwise: yup.string(),
}),
}); });
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
emailOrUsername: "", emailOrUsername: "",
password: "", password: "",
totp: "",
}, },
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
}); });
@@ -34,10 +45,41 @@ const SignInForm = () => {
const signIn = (email: string, password: string) => { const signIn = (email: string, password: string) => {
authService authService
.signIn(email, password) .signIn(email, password)
.then(() => window.location.replace("/")) .then((response) => {
if (response.data["loginToken"]) {
// Prompt the user to enter their totp code
setShowTotp(true);
showNotification({
icon: <TbInfoCircle />,
color: "blue",
radius: "md",
title: "Two-factor authentication required",
message: "Please enter your two-factor authentication code",
});
setLoginToken(response.data["loginToken"]);
} else {
window.location.replace("/");
}
})
.catch(toast.axiosError); .catch(toast.axiosError);
}; };
const signInTotp = (email: string, password: string, totp: string) => {
authService
.signInTotp(email, password, totp, loginToken)
.then(() => window.location.replace("/"))
.catch((error) => {
if (error?.response?.data?.message == "Login token expired") {
toast.error("Login token expired");
// Refresh the page to start over
window.location.reload();
}
toast.axiosError(error);
form.setValues({ totp: "" });
});
};
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title <Title
@@ -59,9 +101,11 @@ const SignInForm = () => {
)} )}
<Paper withBorder shadow="md" p={30} mt={30} radius="md"> <Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form <form
onSubmit={form.onSubmit((values) => onSubmit={form.onSubmit((values) => {
signIn(values.emailOrUsername, values.password) if (showTotp)
)} signInTotp(values.emailOrUsername, values.password, values.totp);
else signIn(values.emailOrUsername, values.password);
})}
> >
<TextInput <TextInput
label="Email or username" label="Email or username"
@@ -74,6 +118,15 @@ const SignInForm = () => {
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
{showTotp && (
<TextInput
variant="filled"
label="Code"
placeholder="******"
mt="md"
{...form.getInputProps("totp")}
/>
)}
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
Sign in Sign in
</Button> </Button>

View File

@@ -33,16 +33,10 @@ const SignUpForm = () => {
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
}); });
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch(toast.axiosError);
};
const signUp = (email: string, username: string, password: string) => { const signUp = (email: string, username: string, password: string) => {
authService authService
.signUp(email, username, password) .signUp(email, username, password)
.then(() => signIn(email, password)) .then(() => window.location.replace("/"))
.catch(toast.axiosError); .catch(toast.axiosError);
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb"; import { TbCloudUpload, TbUpload } from "react-icons/tb";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import { FileUpload } from "../../types/File.type"; import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
@@ -33,9 +33,13 @@ const useStyles = createStyles((theme) => ({
const Dropzone = ({ const Dropzone = ({
isUploading, isUploading,
maxShareSize,
files,
setFiles, setFiles,
}: { }: {
isUploading: boolean; isUploading: boolean;
maxShareSize: number;
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>; setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => { }) => {
const config = useConfig(); const config = useConfig();
@@ -45,18 +49,30 @@ const Dropzone = ({
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<MantineDropzone <MantineDropzone
maxSize={parseInt(config.get("MAX_FILE_SIZE"))}
onReject={(e) => { onReject={(e) => {
toast.error(e[0].errors[0].message); toast.error(e[0].errors[0].message);
}} }}
disabled={isUploading} disabled={isUploading}
openRef={openRef as ForwardedRef<() => void>} openRef={openRef as ForwardedRef<() => void>}
onDrop={(files) => { onDrop={(newFiles: FileUpload[]) => {
const newFiles = files.map((file) => { const fileSizeSum = [...newFiles, ...files].reduce(
(file as FileUpload).uploadingProgress = 0; (n, { size }) => n + size,
return file as FileUpload; 0
}); );
setFiles(newFiles);
if (fileSizeSum > maxShareSize) {
toast.error(
`Your files exceed the maximum share size of ${byteToHumanSizeString(
maxShareSize
)}.`
);
} else {
newFiles = newFiles.map((newFile) => {
newFile.uploadingProgress = 0;
return newFile;
});
setFiles([...newFiles, ...files]);
}
}} }}
className={classes.dropzone} className={classes.dropzone}
radius="md" radius="md"
@@ -70,8 +86,8 @@ const Dropzone = ({
</Text> </Text>
<Text align="center" size="sm" mt="xs" color="dimmed"> <Text align="center" size="sm" mt="xs" color="dimmed">
Drag&apos;n&apos;drop files here to start your share. We can accept Drag&apos;n&apos;drop files here to start your share. We can accept
only files that are less than{" "} only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
{byteStringToHumanSizeString(config.get("MAX_FILE_SIZE"))} in size. in total.
</Text> </Text>
</div> </div>
</MantineDropzone> </MantineDropzone>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { RingProgress } from "@mantine/core"; import { Loader, RingProgress } from "@mantine/core";
import { TbCircleCheck, TbCircleX } from "react-icons/tb"; import { TbCircleCheck } from "react-icons/tb";
const UploadProgressIndicator = ({ progress }: { progress: number }) => { const UploadProgressIndicator = ({ progress }: { progress: number }) => {
if (progress > 0 && progress < 100) { if (progress > 0 && progress < 100) {
return ( return (
@@ -12,7 +12,7 @@ const UploadProgressIndicator = ({ progress }: { progress: number }) => {
} else if (progress >= 100) { } else if (progress >= 100) {
return <TbCircleCheck color="green" size={22} />; return <TbCircleCheck color="green" size={22} />;
} else { } else {
return <TbCircleX color="red" size={22} />; return <Loader color="red" size={19} />;
} }
}; };

View File

@@ -15,7 +15,11 @@ import { TbCopy } from "react-icons/tb";
import { Share } from "../../../types/share.type"; import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => { const showCompletedUploadModal = (
modals: ModalsContextProps,
share: Share,
appUrl: string
) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
@@ -25,18 +29,20 @@ const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
<Title order={4}>Share ready</Title> <Title order={4}>Share ready</Title>
</Stack> </Stack>
), ),
children: <Body share={share} />, children: <Body share={share} appUrl={appUrl} />,
}); });
}; };
const Body = ({ share }: { share: Share }) => { const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
const clipboard = useClipboard({ timeout: 500 }); const clipboard = useClipboard({ timeout: 500 });
const modals = useModals(); const modals = useModals();
const router = useRouter(); const router = useRouter();
const link = `${window.location.origin}/share/${share.id}`;
const link = `${appUrl}/share/${share.id}`;
return ( return (
<Stack align="stretch"> <Stack align="stretch">
<TextInput <TextInput
readOnly
variant="filled" variant="filled"
value={link} value={link}
rightSection={ rightSection={

View File

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

View File

@@ -0,0 +1,30 @@
const defaultPreferences = [
{
key: "colorScheme",
value: "system",
},
];
const get = (key: string) => {
if (typeof window !== "undefined") {
const preferences = JSON.parse(localStorage.getItem("preferences") ?? "{}");
return (
preferences[key] ??
defaultPreferences.find((p) => p.key == key)?.value ??
null
);
}
};
const set = (key: string, value: string) => {
if (typeof window !== "undefined") {
const preferences = JSON.parse(localStorage.getItem("preferences") ?? "{}");
preferences[key] = value;
localStorage.setItem("preferences", JSON.stringify(preferences));
}
};
const usePreferences = () => {
return { get, set };
};
export default usePreferences;

View File

@@ -1,7 +1,10 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { CurrentUser } from "../types/user.type"; import { UserHook } from "../types/user.type";
export const UserContext = createContext<CurrentUser | null>(null); export const UserContext = createContext<UserHook>({
user: null,
setUser: () => {},
});
const useUser = () => { const useUser = () => {
return useContext(UserContext); return useContext(UserContext);

View File

@@ -1,5 +1,6 @@
import { import {
ColorScheme, ColorScheme,
ColorSchemeProvider,
Container, Container,
LoadingOverlay, LoadingOverlay,
MantineProvider, MantineProvider,
@@ -11,7 +12,8 @@ import type { AppProps } from "next/app";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar"; import Header from "../components/navBar/NavBar";
import useConfig, { ConfigContext } from "../hooks/config.hook"; import { ConfigContext } from "../hooks/config.hook";
import usePreferences from "../hooks/usePreferences";
import { UserContext } from "../hooks/user.hook"; import { UserContext } from "../hooks/user.hook";
import authService from "../services/auth.service"; import authService from "../services/auth.service";
import configService from "../services/config.service"; import configService from "../services/config.service";
@@ -25,9 +27,8 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) { function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme(); const systemTheme = useColorScheme();
const router = useRouter(); const router = useRouter();
const config = useConfig(); const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
const [colorScheme, setColorScheme] = useState<ColorScheme>();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null); const [user, setUser] = useState<CurrentUser | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null); const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
@@ -45,18 +46,31 @@ function App({ Component, pageProps }: AppProps) {
getInitalData(); getInitalData();
}, []); }, []);
// Redirect to setup page if setup is not completed
useEffect(() => { useEffect(() => {
if ( if (
configVariables && configVariables &&
configVariables.filter((variable) => variable.key)[0].value == "false" &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath) !["/auth/signUp", "/admin/setup"].includes(router.asPath)
) { ) {
router.push(!user ? "/auth/signUp" : "/admin/setup"); const setupStatus = configVariables.filter(
(variable) => variable.key == "SETUP_STATUS"
)[0].value;
if (setupStatus == "STARTED") {
router.replace("/auth/signUp");
} else if (user && setupStatus == "REGISTERED") {
router.replace("/admin/setup");
} else if (setupStatus == "REGISTERED") {
router.replace("/auth/signIn");
}
} }
}, [router.asPath]); }, [configVariables, router.asPath]);
useEffect(() => { useEffect(() => {
setColorScheme(systemTheme); setColorScheme(
preferences.get("colorScheme") == "system"
? systemTheme
: preferences.get("colorScheme")
);
}, [systemTheme]); }, [systemTheme]);
return ( return (
@@ -65,26 +79,31 @@ function App({ Component, pageProps }: AppProps) {
withNormalizeCSS withNormalizeCSS
theme={{ colorScheme, ...globalStyle }} theme={{ colorScheme, ...globalStyle }}
> >
<GlobalStyle /> <ColorSchemeProvider
<NotificationsProvider> colorScheme={colorScheme}
<ModalsProvider> toggleColorScheme={(value) => setColorScheme(value ?? "light")}
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}> >
{isLoading ? ( <GlobalStyle />
<LoadingOverlay visible overlayOpacity={1} /> <NotificationsProvider>
) : ( <ModalsProvider>
<ConfigContext.Provider value={configVariables}> <GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
<UserContext.Provider value={user} > {isLoading ? (
<LoadingOverlay visible={isLoading} overlayOpacity={1} /> <LoadingOverlay visible overlayOpacity={1} />
<Header /> ) : (
<Container> <ConfigContext.Provider value={configVariables}>
<Component {...pageProps} /> <UserContext.Provider value={{ user, setUser }}>
</Container> <LoadingOverlay visible={isLoading} overlayOpacity={1} />
</UserContext.Provider>{" "} <Header />
</ConfigContext.Provider> <Container>
)} <Component {...pageProps} />
</GlobalLoadingContext.Provider> </Container>
</ModalsProvider> </UserContext.Provider>
</NotificationsProvider> </ConfigContext.Provider>
)}
</GlobalLoadingContext.Provider>
</ModalsProvider>
</NotificationsProvider>
</ColorSchemeProvider>
</MantineProvider> </MantineProvider>
); );
} }

View File

@@ -6,6 +6,7 @@ import {
Paper, Paper,
PasswordInput, PasswordInput,
Stack, Stack,
Tabs,
Text, Text,
TextInput, TextInput,
Title, Title,
@@ -13,14 +14,18 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Tb2Fa } from "react-icons/tb";
import * as yup from "yup"; 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 useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import userService from "../../services/user.service"; import userService from "../../services/user.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const Account = () => { const Account = () => {
const user = useUser(); const { user, setUser } = useUser();
const modals = useModals(); const modals = useModals();
const router = useRouter(); const router = useRouter();
@@ -50,100 +55,228 @@ const Account = () => {
), ),
}); });
const enableTotpForm = useForm({
initialValues: {
password: "",
},
validate: yupResolver(
yup.object().shape({
password: yup.string().min(8),
})
),
});
const disableTotpForm = useForm({
initialValues: {
password: "",
code: "",
},
validate: yupResolver(
yup.object().shape({
password: yup.string().min(8),
code: yup
.string()
.min(6)
.max(6)
.matches(/^[0-9]+$/, { message: "Code must be a number" }),
})
),
});
const refreshUser = async () => setUser(await userService.getCurrentUser());
if (!user) { if (!user) {
router.push("/"); router.push("/");
return; return;
} }
return ( return (
<Container size="sm"> <>
<Title order={3} mb="xs"> <Meta title="My account" />
My account <Container size="sm">
</Title> <Title order={3} mb="xs">
<Paper withBorder p="xl"> My account
<Title order={5} mb="xs">
Account Info
</Title> </Title>
<form <Paper withBorder p="xl">
onSubmit={accountForm.onSubmit((values) => <Title order={5} mb="xs">
userService Account Info
.updateCurrentUser({ </Title>
username: values.username, <form
email: values.email, onSubmit={accountForm.onSubmit((values) =>
}) userService
.then(() => toast.success("User updated successfully")) .updateCurrentUser({
.catch(toast.axiosError) username: values.username,
)} email: values.email,
> })
<Stack> .then(() => toast.success("User updated successfully"))
<TextInput .catch(toast.axiosError)
label="Username" )}
{...accountForm.getInputProps("username")} >
/> <Stack>
<TextInput label="Email" {...accountForm.getInputProps("email")} /> <TextInput
<Group position="right"> label="Username"
<Button type="submit">Save</Button> {...accountForm.getInputProps("username")}
</Group> />
</Stack> <TextInput
</form> label="Email"
</Paper> {...accountForm.getInputProps("email")}
<Paper withBorder p="xl" mt="lg"> />
<Title order={5} mb="xs"> <Group position="right">
Password <Button type="submit">Save</Button>
</Title> </Group>
<form </Stack>
onSubmit={passwordForm.onSubmit((values) => </form>
authService </Paper>
.updatePassword(values.oldPassword, values.password) <Paper withBorder p="xl" mt="lg">
.then(() => { <Title order={5} mb="xs">
toast.success("Password updated successfully"); Password
passwordForm.reset(); </Title>
}) <form
.catch(toast.axiosError) onSubmit={passwordForm.onSubmit((values) =>
)} authService
> .updatePassword(values.oldPassword, values.password)
<Stack> .then(() => {
<PasswordInput toast.success("Password updated successfully");
label="Old password" passwordForm.reset();
{...passwordForm.getInputProps("oldPassword")} })
/> .catch(toast.axiosError)
<PasswordInput )}
label="New password" >
{...passwordForm.getInputProps("password")} <Stack>
/> <PasswordInput
<Group position="right"> label="Old password"
<Button type="submit">Save</Button> {...passwordForm.getInputProps("oldPassword")}
</Group> />
</Stack> <PasswordInput
</form> label="New password"
</Paper> {...passwordForm.getInputProps("password")}
<Center mt={80}> />
<Button <Group position="right">
variant="light" <Button type="submit">Save</Button>
color="red" </Group>
onClick={() => </Stack>
modals.openConfirmModal({ </form>
title: "Account deletion", </Paper>
children: (
<Text size="sm">
Do you really want to delete your account including all your
active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" }, <Paper withBorder p="xl" mt="lg">
confirmProps: { color: "red" }, <Title order={5} mb="xs">
onConfirm: async () => { Security
await userService.removeCurrentUser(); </Title>
window.location.reload();
}, <Tabs defaultValue="totp">
}) <Tabs.List>
} <Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
> TOTP
Delete Account </Tabs.Tab>
</Button> </Tabs.List>
</Center>
</Container> <Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? (
<>
<form
onSubmit={disableTotpForm.onSubmit((values) => {
authService
.disableTOTP(values.code, values.password)
.then(() => {
toast.success("Successfully disabled TOTP");
values.password = "";
values.code = "";
refreshUser();
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
description="Enter your current password to disable TOTP"
label="Password"
{...disableTotpForm.getInputProps("password")}
/>
<TextInput
variant="filled"
label="Code"
placeholder="******"
{...disableTotpForm.getInputProps("code")}
/>
<Group position="right">
<Button color="red" type="submit">
Disable
</Button>
</Group>
</Stack>
</form>
</>
) : (
<>
<form
onSubmit={enableTotpForm.onSubmit((values) => {
authService
.enableTOTP(values.password)
.then((result) => {
showEnableTotpModal(modals, refreshUser, {
qrCode: result.qrCode,
secret: result.totpSecret,
password: values.password,
});
values.password = "";
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="Password"
description="Enter your current password to start enabling TOTP"
{...enableTotpForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Start</Button>
</Group>
</Stack>
</form>
</>
)}
</Tabs.Panel>
</Tabs>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Color scheme
</Title>
<ThemeSwitcher />
</Paper>
<Center mt={80} mb="lg">
<Stack>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all
your active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Stack>
</Center>
</Container>
</>
); );
}; };

View File

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

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Box,
Button, Button,
Center, Center,
Group, Group,
@@ -19,6 +20,7 @@ import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb"; import { TbLink, TbTrash } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal"; import showShareLinkModal from "../../components/account/showShareLinkModal";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type"; import { MyShare } from "../../types/share.type";
@@ -28,7 +30,9 @@ const MyShares = () => {
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
const router = useRouter(); const router = useRouter();
const user = useUser(); const config = useConfig();
const { user } = useUser();
const [shares, setShares] = useState<MyShare[]>(); const [shares, setShares] = useState<MyShare[]>();
@@ -58,79 +62,85 @@ const MyShares = () => {
</Stack> </Stack>
</Center> </Center>
) : ( ) : (
<Table> <Box sx={{ display: "block", overflowX: "auto" }}>
<thead> <Table>
<tr> <thead>
<th>Name</th> <tr>
<th>Visitors</th> <th>Name</th>
<th>Expires at</th> <th>Visitors</th>
<th></th> <th>Expires at</th>
</tr> <th></th>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${window.location.origin}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(modals, share.id);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</Table> {shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}`
);
toast.success(
"Your link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
)} )}
</> </>
); );

View File

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

View File

@@ -1,19 +1,18 @@
import { Col, createStyles, Grid, Paper, Text } from "@mantine/core"; import {
Center,
Col,
createStyles,
Grid,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import Link from "next/link"; import Link from "next/link";
import { TbSettings, TbUsers } from "react-icons/tb"; import { useEffect, useState } from "react";
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
const managementOptions = [ import Meta from "../../components/Meta";
{ import configService from "../../services/config.service";
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
];
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
item: { item: {
@@ -33,27 +32,70 @@ const useStyles = createStyles((theme) => ({
const Admin = () => { const Admin = () => {
const { classes, theme } = useStyles(); const { classes, theme } = useStyles();
const [managementOptions, setManagementOptions] = useState([
{
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
]);
useEffect(() => {
configService.isNewReleaseAvailable().then((isNewReleaseAvailable) => {
if (isNewReleaseAvailable) {
setManagementOptions([
...managementOptions,
{
title: "Update",
icon: TbRefresh,
route:
"https://github.com/stonith404/pingvin-share/releases/latest",
},
]);
}
});
}, []);
return ( return (
<Paper withBorder p={40}> <>
<Grid mt="md"> <Meta title="Administration" />
{managementOptions.map((item) => { <Title mb={30} order={3}>
return ( Administration
<Col xs={6} key={item.route}> </Title>
<Paper <Stack justify="space-between" style={{ height: "calc(100vh - 180px)" }}>
withBorder <Paper withBorder p={40}>
component={Link} <Grid>
href={item.route} {managementOptions.map((item) => {
key={item.title} return (
className={classes.item} <Col xs={6} key={item.route}>
> <Paper
<item.icon color={theme.colors.victoria[8]} size={35} /> withBorder
<Text mt={7}>{item.title}</Text> component={Link}
</Paper> href={item.route}
</Col> key={item.title}
); className={classes.item}
})} >
</Grid> <item.icon color={theme.colors.victoria[8]} size={35} />
</Paper> <Text mt={7}>{item.title}</Text>
</Paper>
</Col>
);
})}
</Grid>
</Paper>
<Center>
<Text size="xs" color="dimmed">
Version {process.env.VERSION}
</Text>
</Center>
</Stack>
</>
); );
}; };

View File

@@ -1,29 +1,28 @@
import { Box, Button, Stack, Text, Title } from "@mantine/core"; import { Box, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import Logo from "../../components/Logo"; import Logo from "../../components/Logo";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import configService from "../../services/config.service";
const Setup = () => { const Setup = () => {
const router = useRouter(); const router = useRouter();
const config = useConfig(); const config = useConfig();
const user = useUser(); const { user } = useUser();
const [isLoading, setIsLoading] = useState(false);
if (!user) { if (!user) {
router.push("/auth/signUp"); router.push("/auth/signUp");
return; return;
} else if (config.get("SETUP_FINISHED")) { } else if (config.get("SETUP_STATUS") == "FINISHED") {
router.push("/"); router.push("/");
return; return;
} }
return ( return (
<> <>
<Meta title="Setup" />
<Stack align="center"> <Stack align="center">
<Logo height={80} width={80} /> <Logo height={80} width={80} />
<Title order={2}>Welcome to Pingvin Share</Title> <Title order={2}>Welcome to Pingvin Share</Title>
@@ -31,19 +30,6 @@ const Setup = () => {
<Box style={{ width: "100%" }}> <Box style={{ width: "100%" }}>
<AdminConfigTable /> <AdminConfigTable />
</Box> </Box>
<Button
loading={isLoading}
onClick={async () => {
setIsLoading(true);
await configService.finishSetup();
setIsLoading(false);
window.location.reload();
}}
mb={70}
mt="lg"
>
Let me in
</Button>
</Stack> </Stack>
</> </>
); );

View File

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

View File

@@ -4,7 +4,7 @@ import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
const SignIn = () => { const SignIn = () => {
const user = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
if (user) { if (user) {
router.replace("/"); router.replace("/");

Some files were not shown because too many files have changed in this diff Show More