Compare commits

...

37 Commits

Author SHA1 Message Date
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
Elias Schneider
bfb47ba6e8 release: 0.3.6 2022-12-13 18:45:52 +01:00
Elias Schneider
c1d87a1c29 test: improve tests for new feature 2022-12-13 18:44:17 +01:00
Elias Schneider
4c7e161217 chore: create prisma migration 2022-12-13 18:39:13 +01:00
Elias Schneider
844c47e129 fix: rerange accordion items 2022-12-13 09:57:48 +01:00
Elias Schneider
9b0c08d0cd fix: remove dot in email link 2022-12-13 09:06:18 +01:00
Elias Schneider
37fda220e9 Merge branch 'main' of https://github.com/stonith404/pingvin-share 2022-12-12 22:38:40 +01:00
Elias Schneider
3b7f5ddc52 Create close_inactive_issues.yml 2022-12-12 14:34:36 +01:00
Elias Schneider
8728fa5207 feat: add description field to share 2022-12-12 11:54:13 +01:00
Elias Schneider
c265129dcc Create SECURITY.md 2022-12-12 11:11:28 +01:00
Elias Schneider
78dd4a7e2a chore: add issue templates 2022-12-12 11:00:10 +01:00
Elias Schneider
3cad4dd487 docs: add synology nas installation by Marius 2022-12-11 12:38:58 +01:00
Elias Schneider
d1d3462056 release: 0.3.5 2022-12-11 12:23:46 +01:00
Elias Schneider
5b01108777 fix: zip doesn't contain file extension 2022-12-11 12:22:01 +01:00
Elias Schneider
3d1d4d0fc7 fix: only create zip if more than one file is in the share 2022-12-11 12:19:42 +01:00
Elias Schneider
7c0d62a429 Update FUNDING.yml 2022-12-10 23:26:57 +01:00
Elias Schneider
d010a8a2d3 feat: upload 3 files at same time 2022-12-10 23:16:10 +01:00
Elias Schneider
9798e26872 fix: setup wizard table doesn't take full width 2022-12-10 18:45:53 +01:00
Elias Schneider
0c10dc674f Merge pull request #37 from Neyxo/improvement-30-docker-image
Improved docker image (size & speed)
2022-12-10 18:33:45 +01:00
Elias Schneider
084e911eed fix: remove unnecessary port expose 2022-12-10 18:32:14 +01:00
Elias Schneider
797f8938ca fix: use node slim to fix arm builds 2022-12-10 18:31:39 +01:00
Elias Schneider
05cbb7b27e fix: jobs never get executed 2022-12-10 17:16:49 +01:00
Elias Schneider
905bab9c86 release: 0.3.4 2022-12-10 15:46:53 +01:00
Jean-Michel Carrel
8e38c5fed7 Improved docker image (size & speed) 2022-12-10 15:34:01 +01:00
Elias Schneider
7e877ce9f4 fix: show alternative to copy button if site is not using https 2022-12-10 13:16:23 +01:00
Elias Schneider
b1bfb09dfd fix: tables on mobile 2022-12-09 14:37:09 +01:00
Elias Schneider
c8a4521677 fix: sign up page available when registration is disabled 2022-12-09 12:05:43 +01:00
78 changed files with 2070 additions and 567 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# These are supported funding model platforms
github: stonith404

45
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "🐛 Bug Report"
description: "Submit a bug report to help us improve"
title: "🐛 Bug Report: "
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out our bug report form 🙏
- type: textarea
id: steps-to-reproduce
validations:
required: true
attributes:
label: "👟 Reproduction steps"
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: "When I ..."
- type: textarea
id: expected-behavior
validations:
required: true
attributes:
label: "👍 Expected behavior"
description: "What did you think would happen?"
placeholder: "It should ..."
- type: textarea
id: actual-behavior
validations:
required: true
attributes:
label: "👎 Actual Behavior"
description: "What did actually happen? Add screenshots, if applicable."
placeholder: "It actually ..."
- type: input
id: operating-system
attributes:
label: "🌐 Browser"
description: "Which browser do you use?"
placeholder: "Firefox"
validations:
required: true
- type: markdown
attributes:
value: |
Before submitting, please check if the issues hasn't been raised before.

29
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
labels: [feature]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out our feature request form 🙏
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: "🔖 Feature description"
description: "A clear and concise description of what the feature is."
placeholder: "You should add ..."
- type: textarea
id: pitch
validations:
required: true
attributes:
label: "🎤 Pitch"
description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable."
placeholder: "In my use-case, ..."
- type: markdown
attributes:
value: |
Before submitting, please check if the issues hasn't been raised before.

View File

@@ -0,0 +1,22 @@
name: Close inactive issues
on:
schedule:
- cron: "00 00 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,3 +1,64 @@
## [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)
### Features
* add description field to share ([8728fa5](https://github.com/stonith404/pingvin-share/commit/8728fa5207524e9aee26d68eafe1b6fff367d749))
### Bug Fixes
* remove dot in email link ([9b0c08d](https://github.com/stonith404/pingvin-share/commit/9b0c08d0cdeeeef217ccba57f593fea9d8858371))
* rerange accordion items ([844c47e](https://github.com/stonith404/pingvin-share/commit/844c47e1290fb0f7dedb41a18be59ed5ab83dabc))
### [0.3.5](https://github.com/stonith404/pingvin-share/compare/v0.3.4...v0.3.5) (2022-12-11)
### Features
* upload 3 files at same time ([d010a8a](https://github.com/stonith404/pingvin-share/commit/d010a8a2d366708b1bb5088e9c1e9f9378d3e023))
### Bug Fixes
* jobs never get executed ([05cbb7b](https://github.com/stonith404/pingvin-share/commit/05cbb7b27ef98a3a80dd9edc318f1dcc9a8bd442))
* only create zip if more than one file is in the share ([3d1d4d0](https://github.com/stonith404/pingvin-share/commit/3d1d4d0fc7c0351724387c3721280c334ae94d98))
* remove unnecessary port expose ([084e911](https://github.com/stonith404/pingvin-share/commit/084e911eed95eb22fea0bf185803ba32c3eda1a9))
* setup wizard table doesn't take full width ([9798e26](https://github.com/stonith404/pingvin-share/commit/9798e26872064edc1049138cf73479b1354a43ed))
* use node slim to fix arm builds ([797f893](https://github.com/stonith404/pingvin-share/commit/797f8938cac9cc3bb788f632d97eba5c49fe98a5))
* zip doesn't contain file extension ([5b01108](https://github.com/stonith404/pingvin-share/commit/5b0110877745f1fcde4952737a93c07ef4a2a92d))
### [0.3.4](https://github.com/stonith404/pingvin-share/compare/v0.3.3...v0.3.4) (2022-12-10)
### Bug Fixes
* show alternative to copy button if site is not using https ([7e877ce](https://github.com/stonith404/pingvin-share/commit/7e877ce9f4b82d61c9b238e17def9f4c29e7aeb8))
* sign up page available when registration is disabled ([c8a4521](https://github.com/stonith404/pingvin-share/commit/c8a4521677280d6aba89d293a1fe0c38adf9f92c))
* tables on mobile ([b1bfb09](https://github.com/stonith404/pingvin-share/commit/b1bfb09dfd5c90cc18847470a9ce1ce8397c1476))
### [0.3.3](https://github.com/stonith404/pingvin-share/compare/v0.3.2...v0.3.3) (2022-12-08) ### [0.3.3](https://github.com/stonith404/pingvin-share/compare/v0.3.2...v0.3.3) (2022-12-08)

View File

@@ -1,27 +1,44 @@
FROM node:18-slim AS frontend-builder # Using node slim because prisma ORM needs libc for ARM builds
# Stage 1: on frontend dependency change
FROM node:18-slim AS frontend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 2: on frontend change
FROM node:18-slim AS frontend-builder
WORKDIR /opt/app
COPY ./frontend . COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build RUN npm run build
FROM node:18-slim AS backend-builder # Stage 3: on backend dependency change
RUN apt-get update && apt-get install -y openssl FROM node:18-slim AS backend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
COPY ./backend .
RUN npx prisma generate
RUN npm run build
# Stage 4:on backend change
FROM node:18-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app
COPY ./backend .
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
RUN npx prisma generate
RUN npm run build && npm prune --production
# Stage 5: Final image
FROM node:18-slim AS runner FROM node:18-slim AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
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
COPY --from=frontend-builder /opt/app/next.config.js .
COPY --from=frontend-builder /opt/app/public ./public COPY --from=frontend-builder /opt/app/public ./public
COPY --from=frontend-builder /opt/app/.next ./.next # Automatically leverage output traces to reduce image size
COPY --from=frontend-builder /opt/app/node_modules ./node_modules # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=frontend-builder /opt/app/.next/standalone ./
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
WORKDIR /opt/app/backend WORKDIR /opt/app/backend
COPY --from=backend-builder /opt/app/node_modules ./node_modules COPY --from=backend-builder /opt/app/node_modules ./node_modules
@@ -31,4 +48,4 @@ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app WORKDIR /opt/app
EXPOSE 3000 EXPOSE 3000
CMD cd frontend && node_modules/.bin/next start & cd backend && npm run prod CMD node frontend/server.js & cd backend && npm run prod

View File

@@ -23,11 +23,17 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
> Pleas note that Pingvin Share is in early stage and could include some bugs > Pleas note that Pingvin Share is in early stage and could include some bugs
### Recommended installation
1. Download the `docker-compose.yml` file 1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d` 2. Run `docker-compose up -d`
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 🐧!
### Additional resources
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
### Upgrade to a new version ### Upgrade to a new version
Run `docker compose pull && docker compose up -d` to update your docker container Run `docker compose pull && docker compose up -d` to update your docker container

7
SECURITY.md Normal file
View File

@@ -0,0 +1,7 @@
# Security Policy
## Supported Versions
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
## Reporting a Vulnerability
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).

View File

@@ -17,6 +17,7 @@
"@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",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"argon2": "^0.30.2", "argon2": "^0.30.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -26,18 +27,20 @@
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.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.0",
"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": "^3.0.2",
"rxjs": "^7.6.0" "rxjs": "^7.6.0",
"ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.5", "@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@prisma/client": "^4.7.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
@@ -46,6 +49,7 @@
"@types/node": "^18.11.10", "@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.6", "@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7", "@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.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
@@ -58,7 +62,6 @@
"prisma": "^4.7.1", "prisma": "^4.7.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.1.1", "tsconfig-paths": "4.1.1",
"typescript": "^4.9.3", "typescript": "^4.9.3",
"wait-on": "^6.0.1" "wait-on": "^6.0.1"
@@ -328,7 +331,6 @@
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
}, },
@@ -340,7 +342,6 @@
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
@@ -443,7 +444,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"dev": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -484,8 +484,7 @@
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.14", "version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
"dev": true
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.15", "version": "0.3.15",
@@ -975,6 +974,48 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@phc/format": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -1013,7 +1054,6 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz",
"integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c"
@@ -1034,14 +1074,13 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz",
"integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==",
"dev": true, "devOptional": true,
"hasInstallScript": true "hasInstallScript": true
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz",
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==", "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q=="
"dev": true
}, },
"node_modules/@sideway/address": { "node_modules/@sideway/address": {
"version": "4.1.4", "version": "4.1.4",
@@ -1067,26 +1106,22 @@
"node_modules/@tsconfig/node10": { "node_modules/@tsconfig/node10": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA=="
"dev": true
}, },
"node_modules/@tsconfig/node12": { "node_modules/@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="
"dev": true
}, },
"node_modules/@tsconfig/node14": { "node_modules/@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="
"dev": true
}, },
"node_modules/@tsconfig/node16": { "node_modules/@tsconfig/node16": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz",
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ=="
"dev": true
}, },
"node_modules/@types/archiver": { "node_modules/@types/archiver": {
"version": "5.3.1", "version": "5.3.1",
@@ -1288,6 +1323,12 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/qrcode-svg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.1.tgz",
"integrity": "sha512-uTuEgFXMknpun//Jj6b1R8T8LiMi9fNpH+cnhZr4b7col2HHTMmjYfm/WOZ7nzjuGpk+oTrpHhePe1qlWtHWTA==",
"dev": true
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -1701,7 +1742,6 @@
"version": "8.8.0", "version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
"dev": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1731,7 +1771,6 @@
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
"dev": true,
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@@ -1939,8 +1978,7 @@
"node_modules/arg": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
"dev": true
}, },
"node_modules/argon2": { "node_modules/argon2": {
"version": "0.30.2", "version": "0.30.2",
@@ -2674,8 +2712,7 @@
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
"dev": true
}, },
"node_modules/cron": { "node_modules/cron": {
"version": "2.0.0", "version": "2.0.0",
@@ -2826,7 +2863,6 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"engines": { "engines": {
"node": ">=0.3.1" "node": ">=0.3.1"
} }
@@ -4736,8 +4772,7 @@
"node_modules/make-error": { "node_modules/make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
"dev": true
}, },
"node_modules/md5": { "node_modules/md5": {
"version": "2.3.0", "version": "2.3.0",
@@ -5320,6 +5355,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5857,7 +5902,7 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz",
"integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "4.7.1" "@prisma/engines": "4.7.1"
@@ -5912,6 +5957,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode-svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
"integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==",
"bin": {
"qrcode-svg": "bin/qrcode-svg.js"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.11.0", "version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
@@ -6787,6 +6840,14 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true "dev": true
}, },
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/through": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -6892,7 +6953,6 @@
"version": "10.9.1", "version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -7052,7 +7112,6 @@
"version": "4.9.3", "version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -7174,8 +7233,7 @@
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="
"dev": true
}, },
"node_modules/validator": { "node_modules/validator": {
"version": "13.7.0", "version": "13.7.0",
@@ -7499,7 +7557,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -7748,7 +7805,6 @@
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"requires": { "requires": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
}, },
@@ -7757,7 +7813,6 @@
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"requires": { "requires": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
@@ -7843,8 +7898,7 @@
"@jridgewell/resolve-uri": { "@jridgewell/resolve-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
"dev": true
}, },
"@jridgewell/set-array": { "@jridgewell/set-array": {
"version": "1.1.2", "version": "1.1.2",
@@ -7878,8 +7932,7 @@
"@jridgewell/sourcemap-codec": { "@jridgewell/sourcemap-codec": {
"version": "1.4.14", "version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
"dev": true
}, },
"@jridgewell/trace-mapping": { "@jridgewell/trace-mapping": {
"version": "0.3.15", "version": "0.3.15",
@@ -8205,6 +8258,48 @@
} }
} }
}, },
"@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA=="
},
"@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"requires": {
"@otplib/core": "^12.0.1"
}
},
"@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"requires": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"@phc/format": { "@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -8234,7 +8329,6 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz",
"integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==", "integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==",
"dev": true,
"requires": { "requires": {
"@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c" "@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c"
} }
@@ -8243,13 +8337,12 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz",
"integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==", "integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==",
"dev": true "devOptional": true
}, },
"@prisma/engines-version": { "@prisma/engines-version": {
"version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c", "version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz",
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==", "integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q=="
"dev": true
}, },
"@sideway/address": { "@sideway/address": {
"version": "4.1.4", "version": "4.1.4",
@@ -8275,26 +8368,22 @@
"@tsconfig/node10": { "@tsconfig/node10": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA=="
"dev": true
}, },
"@tsconfig/node12": { "@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="
"dev": true
}, },
"@tsconfig/node14": { "@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="
"dev": true
}, },
"@tsconfig/node16": { "@tsconfig/node16": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz",
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ=="
"dev": true
}, },
"@types/archiver": { "@types/archiver": {
"version": "5.3.1", "version": "5.3.1",
@@ -8496,6 +8585,12 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"@types/qrcode-svg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.1.tgz",
"integrity": "sha512-uTuEgFXMknpun//Jj6b1R8T8LiMi9fNpH+cnhZr4b7col2HHTMmjYfm/WOZ7nzjuGpk+oTrpHhePe1qlWtHWTA==",
"dev": true
},
"@types/qs": { "@types/qs": {
"version": "6.9.7", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -8816,8 +8911,7 @@
"acorn": { "acorn": {
"version": "8.8.0", "version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="
"dev": true
}, },
"acorn-import-assertions": { "acorn-import-assertions": {
"version": "1.8.0", "version": "1.8.0",
@@ -8836,8 +8930,7 @@
"acorn-walk": { "acorn-walk": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="
"dev": true
}, },
"agent-base": { "agent-base": {
"version": "6.0.2", "version": "6.0.2",
@@ -8991,8 +9084,7 @@
"arg": { "arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
"dev": true
}, },
"argon2": { "argon2": {
"version": "0.30.2", "version": "0.30.2",
@@ -9539,8 +9631,7 @@
"create-require": { "create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
"dev": true
}, },
"cron": { "cron": {
"version": "2.0.0", "version": "2.0.0",
@@ -9648,8 +9739,7 @@
"diff": { "diff": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="
"dev": true
}, },
"dir-glob": { "dir-glob": {
"version": "3.0.1", "version": "3.0.1",
@@ -11123,8 +11213,7 @@
"make-error": { "make-error": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
"dev": true
}, },
"md5": { "md5": {
"version": "2.3.0", "version": "2.3.0",
@@ -11563,6 +11652,16 @@
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true "dev": true
}, },
"otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"requires": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"p-limit": { "p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -11959,7 +12058,7 @@
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz",
"integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==", "integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==",
"dev": true, "devOptional": true,
"requires": { "requires": {
"@prisma/engines": "4.7.1" "@prisma/engines": "4.7.1"
} }
@@ -12000,6 +12099,11 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true "dev": true
}, },
"qrcode-svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
"integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw=="
},
"qs": { "qs": {
"version": "6.11.0", "version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
@@ -12645,6 +12749,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true "dev": true
}, },
"thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA=="
},
"through": { "through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -12724,7 +12833,6 @@
"version": "10.9.1", "version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"requires": { "requires": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -12835,8 +12943,7 @@
"typescript": { "typescript": {
"version": "4.9.3", "version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA=="
"dev": true
}, },
"uglify-js": { "uglify-js": {
"version": "3.17.3", "version": "3.17.3",
@@ -12916,8 +13023,7 @@
"v8-compile-cache-lib": { "v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="
"dev": true
}, },
"validator": { "validator": {
"version": "13.7.0", "version": "13.7.0",
@@ -13157,8 +13263,7 @@
"yn": { "yn": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="
"dev": true
}, },
"yocto-queue": { "yocto-queue": {
"version": "0.1.0", "version": "0.1.0",

View File

@@ -22,6 +22,7 @@
"@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",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"argon2": "^0.30.2", "argon2": "^0.30.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -31,18 +32,20 @@
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.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.0",
"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": "^3.0.2",
"rxjs": "^7.6.0" "rxjs": "^7.6.0",
"ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.5", "@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@prisma/client": "^4.7.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
@@ -51,6 +54,7 @@
"@types/node": "^18.11.10", "@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.6", "@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7", "@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.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
@@ -63,7 +67,6 @@
"prisma": "^4.7.1", "prisma": "^4.7.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.1.1", "tsconfig-paths": "4.1.1",
"typescript": "^4.9.3", "typescript": "^4.9.3",
"wait-on": "^6.0.1" "wait-on": "^6.0.1"

View File

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

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

@@ -19,6 +19,11 @@ model User {
shares Share[] shares Share[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
loginTokens LoginToken[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpSecret String?
} }
model RefreshToken { model RefreshToken {
@@ -31,6 +36,17 @@ 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())
@@ -39,6 +55,7 @@ model Share {
isZipReady Boolean @default(false) isZipReady Boolean @default(false)
views Int @default(0) views Int @default(0)
expiration DateTime expiration DateTime
description String?
creatorId String? creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
@@ -84,6 +101,7 @@ 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)

View File

@@ -7,6 +7,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether the setup has been finished", description: "Whether the setup has been finished",
type: "boolean", type: "boolean",
value: "false", value: "false",
category: "internal",
secret: false, secret: false,
locked: true, locked: true,
}, },
@@ -15,6 +16,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "On which URL Pingvin Share is available", description: "On which URL Pingvin Share is available",
type: "string", type: "string",
value: "http://localhost:3000", value: "http://localhost:3000",
category: "general",
secret: false, secret: false,
}, },
{ {
@@ -22,6 +24,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether to show the home page", description: "Whether to show the home page",
type: "boolean", type: "boolean",
value: "true", value: "true",
category: "general",
secret: false, secret: false,
}, },
{ {
@@ -29,6 +32,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether registration is allowed", description: "Whether registration is allowed",
type: "boolean", type: "boolean",
value: "true", value: "true",
category: "share",
secret: false, secret: false,
}, },
{ {
@@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether unauthorized users can create shares", description: "Whether unauthorized users can create shares",
type: "boolean", type: "boolean",
value: "false", value: "false",
category: "share",
secret: false, secret: false,
}, },
{ {
@@ -43,6 +48,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Maximum file size in bytes", description: "Maximum file size in bytes",
type: "number", type: "number",
value: "1000000000", value: "1000000000",
category: "share",
secret: false, secret: false,
}, },
{ {
@@ -50,6 +56,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
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,
},
{
key: "TOTP_SECRET",
description: "A 16 byte random string used to generate TOTP secrets",
type: "string",
value: crypto.randomBytes(16).toString("base64"),
category: "internal",
locked: true, locked: true,
}, },
{ {
@@ -58,31 +73,52 @@ const configVariables: Prisma.ConfigCreateInput[] = [
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "boolean", type: "boolean",
value: "false", value: "false",
category: "email",
secret: false, secret: false,
}, },
{
key: "EMAIL_MESSAGE",
description:
"Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
key: "EMAIL_SUBJECT",
description: "Subject of the email which gets sent to the recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{ {
key: "SMTP_HOST", key: "SMTP_HOST",
description: "Host of the SMTP server", description: "Host of the SMTP server",
type: "string", type: "string",
value: "", value: "",
category: "email",
}, },
{ {
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: "email",
}, },
{ {
key: "SMTP_EMAIL", key: "SMTP_EMAIL",
description: "Email address which the emails get sent from", description: "Email address which the emails get sent from",
type: "string", type: "string",
value: "", value: "",
category: "email",
}, },
{ {
key: "SMTP_USERNAME", key: "SMTP_USERNAME",
description: "Username of the SMTP server", description: "Username of the SMTP server",
type: "string", type: "string",
value: "", value: "",
category: "email",
}, },
{ {
key: "SMTP_PASSWORD", key: "SMTP_PASSWORD",
@@ -90,6 +126,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
type: "string", type: "string",
value: "", value: "",
obscured: true, obscured: true,
category: "email",
}, },
]; ];

View File

@@ -13,6 +13,7 @@ import { FileModule } from "./file/file.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";
@Module({ @Module({
imports: [ imports: [
@@ -22,6 +23,7 @@ import { UserModule } from "./user/user.module";
EmailModule, EmailModule,
PrismaModule, PrismaModule,
ConfigModule, ConfigModule,
JobsModule,
UserModule, UserModule,
MulterModule.registerAsync({ MulterModule.registerAsync({
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({

View File

@@ -11,17 +11,22 @@ import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
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 { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto"; import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.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
) {} ) {}
@@ -40,6 +45,13 @@ export class AuthController {
return this.authService.signIn(dto); return this.authService.signIn(dto);
} }
@Throttle(10, 5 * 60)
@Post("signIn/totp")
@HttpCode(200)
signInTotp(@Body() dto: AuthSignInTotpDTO) {
return this.authTotpService.signInTotp(dto);
}
@Patch("password") @Patch("password")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) { async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
@@ -54,4 +66,23 @@ export class AuthController {
); );
return { accessToken }; return { accessToken };
} }
@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);
}
} }

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

@@ -63,6 +63,14 @@ 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");
// TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled
if (user.totpVerified) {
const loginToken = await this.createLoginToken(user.id);
return { loginToken };
}
const accessToken = await this.createAccessToken(user); const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id); const refreshToken = await this.createRefreshToken(user.id);
@@ -70,7 +78,7 @@ export class AuthService {
} }
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);
@@ -115,4 +123,14 @@ export class AuthService {
return refreshToken; return refreshToken;
} }
async createLoginToken(userId: string) {
const loginToken = (
await this.prisma.loginToken.create({
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
})
).token;
return loginToken;
}
} }

View File

@@ -0,0 +1,226 @@
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 accessToken = await this.authService.createAccessToken(user);
const refreshToken = await this.authService.createRefreshToken(user.id);
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

@@ -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,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,12 +28,10 @@ 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")
@@ -44,4 +39,10 @@ export class ConfigController {
async finishSetup() { async finishSetup() {
return await this.configService.finishSetup(); return await this.configService.finishSetup();
} }
@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,7 +23,8 @@ 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() {
@@ -38,6 +39,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 +55,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 },

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,28 +7,40 @@ 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) { transporter = nodemailer.createTransport({
// create reusable transporter object using the default SMTP transport host: this.config.get("SMTP_HOST"),
const transporter = nodemailer.createTransport({ port: parseInt(this.config.get("SMTP_PORT")),
host: this.config.get("SMTP_HOST"), secure: parseInt(this.config.get("SMTP_PORT")) == 465,
port: parseInt(this.config.get("SMTP_PORT")), auth: {
secure: parseInt(this.config.get("SMTP_PORT")) == 465, user: this.config.get("SMTP_USERNAME"),
auth: { pass: this.config.get("SMTP_PASSWORD"),
user: this.config.get("SMTP_USERNAME"), },
pass: this.config.get("SMTP_PASSWORD"), });
},
});
async sendMail(recipientEmail: string, shareId: string, creator: User) {
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS")) if (!this.config.get("ENABLE_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.transporter.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("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("EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator.username)
.replaceAll("{shareUrl}", shareUrl),
});
}
async sendTestMail(recipientEmail: string) {
await this.transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
}); });
} }
} }

View File

@@ -45,7 +45,6 @@ export class FileController {
@Get(":fileId/download") @Get(":fileId/download")
@UseGuards(ShareSecurityGuard) @UseGuards(ShareSecurityGuard)
async getFileDownloadUrl( async getFileDownloadUrl(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string, @Param("shareId") shareId: string,
@Param("fileId") fileId: string @Param("fileId") fileId: string
) { ) {
@@ -57,16 +56,11 @@ export class FileController {
@Get("zip/download") @Get("zip/download")
@UseGuards(ShareSecurityGuard) @UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL( async getZipArchiveDownloadURL(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string, @Param("shareId") shareId: string,
@Param("fileId") fileId: string @Param("fileId") fileId: string
) { ) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId); const url = this.fileService.getFileDownloadUrl(shareId, fileId);
res.set({
"Content-Type": "application/zip",
});
return { url }; return { url };
} }
@@ -79,7 +73,7 @@ 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}"`, "Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
}); });
return new StreamableFile(zip); return new StreamableFile(zip);

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module";
import { JobsService } from "./jobs.service";
@Module({
imports: [FileModule],
providers: [JobsService],
})
export class JobsModule {}

View File

@@ -1,5 +1,4 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
@Injectable() @Injectable()
@@ -8,7 +7,7 @@ export class PrismaService extends PrismaClient {
super({ super({
datasources: { datasources: {
db: { db: {
url: "file:../data/pingvin-share.db", url: "file:../data/pingvin-share.db?connection_limit=1",
}, },
}, },
}); });

View File

@@ -1,9 +1,11 @@
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import {
IsEmail, IsEmail,
IsOptional,
IsString, IsString,
Length, Length,
Matches, Matches,
MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { ShareSecurityDTO } from "./shareSecurity.dto"; import { ShareSecurityDTO } from "./shareSecurity.dto";
@@ -19,6 +21,10 @@ export class CreateShareDTO {
@IsString() @IsString()
expiration: string; expiration: string;
@MaxLength(512)
@IsOptional()
description: string;
@IsEmail({}, { each: true }) @IsEmail({}, { each: true })
recipients: string[]; recipients: string[];

View File

@@ -17,6 +17,9 @@ export class ShareDTO {
@Type(() => PublicUserDTO) @Type(() => PublicUserDTO)
creator: PublicUserDTO; creator: PublicUserDTO;
@Expose()
description: string;
from(partial: Partial<ShareDTO>) { from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
} }

View File

@@ -105,9 +105,10 @@ export class ShareService {
); );
// Asynchronously create a zip of all files // Asynchronously create a zip of all files
this.createZip(id).then(() => if (share.files.length > 1)
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) this.createZip(id).then(() =>
); this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
);
// Send email for each recepient // Send email for each recepient
for (const recepient of share.recipients) { for (const recepient of share.recipients) {

View File

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

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": "243b0832-3a6a-4389-bb71-4d988c0a86d9", "_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a",
"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"
@@ -431,7 +431,7 @@
" 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(responseBody).to.have.property(\"expiration\")", " pm.expect(responseBody).to.have.property(\"expiration\")",
" pm.expect(Object.keys(responseBody).length).be.equal(2)", " pm.expect(Object.keys(responseBody).length).be.equal(3)",
"});", "});",
"" ""
], ],
@@ -517,6 +517,60 @@
}, },
"response": [] "response": []
}, },
{
"name": "Upload file 2",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", () => {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body correct\", () => {",
" const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"id\")",
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
"});"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "file",
"type": "file",
"src": "./test/system/test-file.txt"
}
]
},
"url": {
"raw": "{{API_URL}}/shares/:shareId/files",
"host": [
"{{API_URL}}"
],
"path": [
"shares",
":shareId",
"files"
],
"variable": [
{
"key": "shareId",
"value": "test-share"
}
]
}
},
"response": []
},
{ {
"name": "Complete share", "name": "Complete share",
"event": [ "event": [
@@ -532,7 +586,7 @@
" 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(responseBody).to.have.property(\"expiration\")", " pm.expect(responseBody).to.have.property(\"expiration\")",
" pm.expect(Object.keys(responseBody).length).be.equal(2)", " pm.expect(Object.keys(responseBody).length).be.equal(3)",
"});", "});",
"" ""
], ],
@@ -942,9 +996,9 @@
" pm.response.to.have.status(200);", " pm.response.to.have.status(200);",
"});", "});",
"", "",
"pm.test(\"Response contains 1 file\", () => {", "pm.test(\"Response contains 2 files\", () => {",
" const responseBody = pm.response.json();", " const responseBody = pm.response.json();",
" pm.expect(responseBody.files.length).be.equal(1)", " pm.expect(responseBody.files.length).be.equal(2)",
"});", "});",
"", "",
"", "",

View File

@@ -1,10 +1,8 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
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();

View File

@@ -26,6 +26,7 @@
"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",
"p-limit": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.7.1", "react-icons": "^4.7.1",
@@ -6161,6 +6162,35 @@
} }
}, },
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
"integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
@@ -6175,14 +6205,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-locate": { "node_modules/p-locate/node_modules/yocto-queue": {
"version": "5.0.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true, "dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -8049,12 +8076,11 @@
} }
}, },
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==",
"dev": true,
"engines": { "engines": {
"node": ">=10" "node": ">=12.20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@@ -12482,12 +12508,11 @@
} }
}, },
"p-limit": { "p-limit": {
"version": "3.1.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
"dev": true,
"requires": { "requires": {
"yocto-queue": "^0.1.0" "yocto-queue": "^1.0.0"
} }
}, },
"p-locate": { "p-locate": {
@@ -12497,6 +12522,23 @@
"dev": true, "dev": true,
"requires": { "requires": {
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
},
"dependencies": {
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"requires": {
"yocto-queue": "^0.1.0"
}
},
"yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
}
} }
}, },
"p-map": { "p-map": {
@@ -13865,10 +13907,9 @@
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
}, },
"yocto-queue": { "yocto-queue": {
"version": "0.1.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g=="
"dev": true
}, },
"yup": { "yup": {
"version": "0.32.11", "version": "0.32.11",

View File

@@ -27,6 +27,7 @@
"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",
"p-limit": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.7.1", "react-icons": "^4.7.1",

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

@@ -0,0 +1,16 @@
import { Stack, TextInput } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => {
const link = `${window.location.origin}/share/${shareId}`;
return modals.openModal({
title: "Share link",
children: (
<Stack align="stretch">
<TextInput variant="filled" value={link} />
</Stack>
),
});
};
export default showShareLinkModal;

View File

@@ -1,96 +0,0 @@
import { ActionIcon, 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 (
<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>
);
};
export default AdminConfigTable;

View File

@@ -18,8 +18,8 @@ const ManageUserTable = ({
const modals = useModals(); const modals = useModals();
return ( return (
<Box sx={{ display: "block", overflowX: "auto", whiteSpace: "nowrap" }}> <Box sx={{ display: "block", overflowX: "auto" }}>
<Table verticalSpacing="sm" highlightOnHover> <Table verticalSpacing="sm">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Username</th>

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%" }}
onChange={(e) => onValueChange(configVariable, e.target.value)}
{...form.getInputProps("stringValue")}
/>
) : (
<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,140 @@
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)");
let updatedConfigVariables: UpdateConfig[] = [];
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key
);
if (index > -1) {
updatedConfigVariables[index] = configVariable;
} else {
updatedConfigVariables.push(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);
});
};
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 == "email" && (
<Group position="right">
<TestEmailButton />
</Group>
)}
</Paper>
);
}
)}
<Group position="right">
<Button
onClick={() => {
if (config.get("SETUP_FINISHED")) {
configService
.updateMany(updatedConfigVariables)
.then(() =>
toast.success("Configurations updated successfully")
)
.catch(toast.axiosError);
} else {
configService
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
window.location.reload();
})
.catch(toast.axiosError);
}
}}
>
Save
</Button>
</Group>
</Box>
);
};
export default AdminConfigTable;

View File

@@ -0,0 +1,27 @@
import { Button } from "@mantine/core";
import useUser from "../../../hooks/user.hook";
import configService from "../../../services/config.service";
import toast from "../../../utils/toast.util";
const TestEmailButton = () => {
const { user } = useUser();
return (
<Button
variant="light"
onClick={() =>
configService
.sendTestEmail(user!.email)
.then(() => toast.success("Email sent successfully"))
.catch(() =>
toast.error(
"Failed to send the email. Please check the backend logs for more information."
)
)
}
>
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

@@ -5,7 +5,7 @@ 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>

View File

@@ -107,7 +107,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);

View File

@@ -9,35 +9,10 @@ const FileList = ({
shareId, shareId,
isLoading, isLoading,
}: { }: {
files: any[]; files?: any[];
shareId: string; shareId: string;
isLoading: boolean; isLoading: boolean;
}) => { }) => {
const rows = files.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size)}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (
<Loader size={22} />
) : (
<TbCircleCheck color="green" size={22} />
)
) : (
<ActionIcon
size={25}
onClick={async () => {
await shareService.downloadFile(shareId, file.id);
}}
>
<TbDownload />
</ActionIcon>
)}
</td>
</tr>
));
return ( return (
<Table> <Table>
<thead> <thead>
@@ -47,7 +22,34 @@ const FileList = ({
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody>{isLoading ? skeletonRows : rows}</tbody> <tbody>
{isLoading
? skeletonRows
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size)}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (
<Loader size={22} />
) : (
<TbCircleCheck color="green" size={22} />
)
) : (
<ActionIcon
size={25}
onClick={async () => {
await shareService.downloadFile(shareId, file.id);
}}
>
<TbDownload />
</ActionIcon>
)}
</td>
</tr>
))}
</tbody>
</Table> </Table>
); );
}; };

View File

@@ -40,14 +40,16 @@ const Body = ({ share }: { share: Share }) => {
variant="filled" variant="filled"
value={link} value={link}
rightSection={ rightSection={
<ActionIcon window.isSecureContext && (
onClick={() => { <ActionIcon
clipboard.copy(link); onClick={() => {
toast.success("Your link was copied to the keyboard."); clipboard.copy(link);
}} toast.success("Your link was copied to the keyboard.");
> }}
<TbCopy /> >
</ActionIcon> <TbCopy />
</ActionIcon>
)
} }
/> />
<Text <Text

View File

@@ -12,6 +12,7 @@ import {
Select, Select,
Stack, Stack,
Text, Text,
Textarea,
TextInput, TextInput,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@@ -22,7 +23,7 @@ import { useState } from "react";
import { TbAlertCircle } from "react-icons/tb"; 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 { ShareSecurity } from "../../../types/share.type"; import { CreateShare } from "../../../types/share.type";
import ExpirationPreview from "../ExpirationPreview"; import ExpirationPreview from "../ExpirationPreview";
const showCreateUploadModal = ( const showCreateUploadModal = (
@@ -32,12 +33,7 @@ const showCreateUploadModal = (
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean; enableEmailRecepients: boolean;
}, },
uploadCallback: ( uploadCallback: (createShare: CreateShare) => void
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Share</Title>, title: <Title order={4}>Share</Title>,
@@ -54,12 +50,7 @@ const CreateUploadModalBody = ({
uploadCallback, uploadCallback,
options, options,
}: { }: {
uploadCallback: ( uploadCallback: (createShare: CreateShare) => void;
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => void;
options: { options: {
isUserSignedIn: boolean; isUserSignedIn: boolean;
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
@@ -88,6 +79,7 @@ const CreateUploadModalBody = ({
recipients: [] as string[], recipients: [] as string[],
password: undefined, password: undefined,
maxViews: undefined, maxViews: undefined,
description: undefined,
expiration_num: 1, expiration_num: 1,
expiration_unit: "-days", expiration_unit: "-days",
never_expires: false, never_expires: false,
@@ -116,9 +108,15 @@ const CreateUploadModalBody = ({
const expiration = form.values.never_expires const expiration = form.values.never_expires
? "never" ? "never"
: form.values.expiration_num + form.values.expiration_unit; : form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, values.recipients, { uploadCallback({
password: values.password, id: values.link,
maxViews: values.maxViews, expiration: expiration,
recipients: values.recipients,
description: values.description,
security: {
password: values.password,
maxViews: values.maxViews,
},
}); });
modals.closeAll(); modals.closeAll();
} }
@@ -228,6 +226,18 @@ const CreateUploadModalBody = ({
{ExpirationPreview({ form })} {ExpirationPreview({ form })}
</Text> </Text>
<Accordion> <Accordion>
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
<Accordion.Control>Description</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<Textarea
variant="filled"
placeholder="Note for the recepients"
{...form.getInputProps("description")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{options.enableEmailRecepients && ( {options.enableEmailRecepients && (
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}> <Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
<Accordion.Control>Email recipients</Accordion.Control> <Accordion.Control>Email recipients</Accordion.Control>
@@ -258,6 +268,7 @@ const CreateUploadModalBody = ({
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
)} )}
<Accordion.Item value="security" sx={{ borderBottom: "none" }}> <Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control> <Accordion.Control>Security options</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>

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,9 @@ 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>(); const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
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);
@@ -56,7 +58,11 @@ function App({ Component, pageProps }: AppProps) {
}, [router.asPath]); }, [router.asPath]);
useEffect(() => { useEffect(() => {
setColorScheme(systemTheme); setColorScheme(
preferences.get("colorScheme") == "system"
? systemTheme
: preferences.get("colorScheme")
);
}, [systemTheme]); }, [systemTheme]);
return ( return (
@@ -65,26 +71,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,17 @@ 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 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,6 +54,36 @@ 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;
@@ -117,31 +151,123 @@ const Account = () => {
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
<Center mt={80}>
<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" }, <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>
<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> </Center>
</Container> </Container>
); );

View File

@@ -17,6 +17,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; 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 Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
@@ -27,7 +28,7 @@ const MyShares = () => {
const modals = useModals(); const modals = useModals();
const clipboard = useClipboard(); const clipboard = useClipboard();
const router = useRouter(); const router = useRouter();
const user = useUser(); const { user } = useUser();
const [shares, setShares] = useState<MyShare[]>(); const [shares, setShares] = useState<MyShare[]>();
@@ -83,12 +84,16 @@ const MyShares = () => {
variant="light" variant="light"
size={25} size={25}
onClick={() => { onClick={() => {
clipboard.copy( if (window.isSecureContext) {
`${window.location.origin}/share/${share.id}` clipboard.copy(
); `${window.location.origin}/share/${share.id}`
toast.success( );
"Your link was copied to the keyboard." toast.success(
); "Your link was copied to the keyboard."
);
} else {
showShareLinkModal(modals, share.id);
}
}} }}
> >
<TbLink /> <TbLink />

View File

@@ -1,5 +1,5 @@
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";
const AdminConfig = () => { const AdminConfig = () => {
return ( return (

View File

@@ -1,18 +1,15 @@
import { 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 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");
@@ -28,20 +25,9 @@ const Setup = () => {
<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>
<Text>Let's customize Pingvin Share for you! </Text> <Text>Let's customize Pingvin Share for you! </Text>
<AdminConfigTable /> <Box style={{ width: "100%" }}>
<Button <AdminConfigTable />
loading={isLoading} </Box>
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,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("/");

View File

@@ -6,11 +6,11 @@ import useUser from "../../hooks/user.hook";
const SignUp = () => { const SignUp = () => {
const config = useConfig(); const config = useConfig();
const user = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
if (user) { if (user) {
router.replace("/"); router.replace("/");
} else if (config.get("ALLOW_REGISTRATION") == "false") { } else if (!config.get("ALLOW_REGISTRATION")) {
router.replace("/auth/signIn"); router.replace("/auth/signIn");
} else { } else {
return ( return (

View File

@@ -70,7 +70,7 @@ const useStyles = createStyles((theme) => ({
export default function Home() { export default function Home() {
const config = useConfig(); const config = useConfig();
const user = useUser(); const { user } = useUser();
const { classes } = useStyles(); const { classes } = useStyles();
const router = useRouter(); const router = useRouter();

View File

@@ -1,4 +1,4 @@
import { Group } from "@mantine/core"; import { Box, Group, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -8,6 +8,7 @@ import FileList from "../../components/share/FileList";
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal"; import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
import showErrorModal from "../../components/share/showErrorModal"; import showErrorModal from "../../components/share/showErrorModal";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { Share as ShareType } from "../../types/share.type";
export function getServerSideProps(context: GetServerSidePropsContext) { export function getServerSideProps(context: GetServerSidePropsContext) {
return { return {
@@ -17,7 +18,7 @@ export function getServerSideProps(context: GetServerSidePropsContext) {
const Share = ({ shareId }: { shareId: string }) => { const Share = ({ shareId }: { shareId: string }) => {
const modals = useModals(); const modals = useModals();
const [fileList, setFileList] = useState<any[]>([]); const [share, setShare] = useState<ShareType>();
const getShareToken = async (password?: string) => { const getShareToken = async (password?: string) => {
await shareService await shareService
@@ -41,7 +42,7 @@ const Share = ({ shareId }: { shareId: string }) => {
shareService shareService
.get(shareId) .get(shareId)
.then((share) => { .then((share) => {
setFileList(share.files); setShare(share);
}) })
.catch((e) => { .catch((e) => {
const { error } = e.response.data; const { error } = e.response.data;
@@ -77,14 +78,16 @@ const Share = ({ shareId }: { shareId: string }) => {
title={`Share ${shareId}`} title={`Share ${shareId}`}
description="Look what I've shared with you." description="Look what I've shared with you."
/> />
<Group position="right" mb="lg">
<DownloadAllButton shareId={shareId} /> <Group position="apart" mb="lg">
<Box style={{ maxWidth: "70%" }}>
<Title order={3}>{share?.id}</Title>
<Text size="sm">{share?.description}</Text>
</Box>
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
</Group> </Group>
<FileList
files={fileList} <FileList files={share?.files} shareId={shareId} isLoading={!share} />
shareId={shareId}
isLoading={fileList.length == 0}
/>
</> </>
); );
}; };

View File

@@ -2,6 +2,7 @@ import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import axios from "axios"; import axios from "axios";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import pLimit from "p-limit";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
import Dropzone from "../components/upload/Dropzone"; import Dropzone from "../components/upload/Dropzone";
@@ -12,26 +13,22 @@ import useConfig from "../hooks/config.hook";
import useUser from "../hooks/user.hook"; import useUser from "../hooks/user.hook";
import shareService from "../services/share.service"; import shareService from "../services/share.service";
import { FileUpload } from "../types/File.type"; import { FileUpload } from "../types/File.type";
import { ShareSecurity } from "../types/share.type"; import { CreateShare, Share } from "../types/share.type";
import toast from "../utils/toast.util"; import toast from "../utils/toast.util";
let share: any; let createdShare: Share;
const promiseLimit = pLimit(3);
const Upload = () => { const Upload = () => {
const router = useRouter(); const router = useRouter();
const modals = useModals(); const modals = useModals();
const user = useUser(); const { user } = useUser();
const config = useConfig(); const config = useConfig();
const [files, setFiles] = useState<FileUpload[]>([]); const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false); const [isUploading, setisUploading] = useState(false);
const uploadFiles = async ( const uploadFiles = async (share: CreateShare) => {
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => {
setisUploading(true); setisUploading(true);
try { try {
setFiles((files) => setFiles((files) =>
@@ -40,8 +37,10 @@ const Upload = () => {
return file; return file;
}) })
); );
share = await shareService.create(id, expiration, recipients, security); createdShare = await shareService.create(share);
for (let i = 0; i < files.length; i++) {
const uploadPromises = files.map((file, i) => {
// Callback to indicate current upload progress
const progressCallBack = (progress: number) => { const progressCallBack = (progress: number) => {
setFiles((files) => { setFiles((files) => {
return files.map((file, callbackIndex) => { return files.map((file, callbackIndex) => {
@@ -54,11 +53,15 @@ const Upload = () => {
}; };
try { try {
await shareService.uploadFile(share.id, files[i], progressCallBack); return promiseLimit(() =>
shareService.uploadFile(share.id, file, progressCallBack)
);
} catch { } catch {
files[i].uploadingProgress = -1; file.uploadingProgress = -1;
} }
} });
await Promise.all(uploadPromises);
} catch (e) { } catch (e) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
toast.error(e.response?.data?.message ?? "An unkown error occured."); toast.error(e.response?.data?.message ?? "An unkown error occured.");
@@ -84,9 +87,9 @@ const Upload = () => {
toast.error(`${fileErrorCount} file(s) failed to upload. Try again.`); toast.error(`${fileErrorCount} file(s) failed to upload. Try again.`);
} else { } else {
shareService shareService
.completeShare(share.id) .completeShare(createdShare.id)
.then(() => { .then(() => {
showCompletedUploadModal(modals, share); showCompletedUploadModal(modals, createdShare);
setFiles([]); setFiles([]);
}) })
.catch(() => .catch(() =>

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookies } from "cookies-next"; import { getCookie, setCookie } from "cookies-next";
import * as jose from "jose"; import * as jose from "jose";
import api from "./api.service"; import api from "./api.service";
@@ -11,33 +11,69 @@ const signIn = async (emailOrUsername: string, password: string) => {
...emailOrUsernameBody, ...emailOrUsernameBody,
password, password,
}); });
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken); setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response;
};
const signInTotp = async (
emailOrUsername: string,
password: string,
totp: string,
loginToken: string
) => {
const emailOrUsernameBody = emailOrUsername.includes("@")
? { email: emailOrUsername }
: { username: emailOrUsername };
const response = await api.post("auth/signIn/totp", {
...emailOrUsernameBody,
password,
totp,
loginToken,
});
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
const signUp = async (email: string, username: string, password: string) => { const signUp = async (email: string, username: string, password: string) => {
return await api.post("auth/signUp", { email, username, password }); const response = await api.post("auth/signUp", { email, username, password });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response;
}; };
const signOut = () => { const signOut = () => {
setCookies("access_token", null); setCookie("access_token", null);
setCookies("refresh_token", null); setCookie("refresh_token", null);
window.location.reload(); window.location.reload();
}; };
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
try { try {
const currentAccessToken = getCookie("access_token") as string; const accessToken = getCookie("access_token") as string;
const refreshToken = getCookie("refresh_token");
if ( if (
currentAccessToken && (accessToken &&
(jose.decodeJwt(currentAccessToken).exp ?? 0) * 1000 < (jose.decodeJwt(accessToken).exp ?? 0) * 1000 <
Date.now() + 2 * 60 * 1000 Date.now() + 2 * 60 * 1000) ||
(refreshToken && !accessToken)
) { ) {
const refreshToken = getCookie("refresh_token");
const response = await api.post("auth/token", { refreshToken }); const response = await api.post("auth/token", { refreshToken });
setCookies("access_token", response.data.accessToken); setCookie("access_token", response.data.accessToken);
} }
} catch { } catch {
console.info("Refresh token invalid or expired"); console.info("Refresh token invalid or expired");
@@ -48,10 +84,38 @@ const updatePassword = async (oldPassword: string, password: string) => {
await api.patch("/auth/password", { oldPassword, password }); await api.patch("/auth/password", { oldPassword, password });
}; };
const enableTOTP = async (password: string) => {
const { data } = await api.post("/auth/totp/enable", { password });
return {
totpAuthUrl: data.totpAuthUrl,
totpSecret: data.totpSecret,
qrCode: data.qrCode,
};
};
const verifyTOTP = async (totpCode: string, password: string) => {
await api.post("/auth/totp/verify", {
code: totpCode,
password,
});
};
const disableTOTP = async (totpCode: string, password: string) => {
await api.post("/auth/totp/disable", {
code: totpCode,
password,
});
};
export default { export default {
signIn, signIn,
signInTotp,
signUp, signUp,
signOut, signOut,
refreshAccessToken, refreshAccessToken,
updatePassword, updatePassword,
enableTOTP,
verifyTOTP,
disableTOTP,
}; };

View File

@@ -1,4 +1,4 @@
import Config, { AdminConfig } from "../types/config.type"; import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
import api from "./api.service"; import api from "./api.service";
const list = async (): Promise<Config[]> => { const list = async (): Promise<Config[]> => {
@@ -9,11 +9,8 @@ const listForAdmin = async (): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data; return (await api.get("/configs/admin")).data;
}; };
const update = async ( const updateMany = async (data: UpdateConfig[]): Promise<AdminConfig[]> => {
key: string, return (await api.patch("/configs/admin", data)).data;
value: string | number | boolean
): Promise<AdminConfig[]> => {
return (await api.patch(`/configs/admin/${key}`, { value })).data;
}; };
const get = (key: string, configVariables: Config[]): any => { const get = (key: string, configVariables: Config[]): any => {
@@ -27,17 +24,23 @@ const get = (key: string, configVariables: Config[]): any => {
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;
}; };
const finishSetup = async (): Promise<AdminConfig[]> => { const finishSetup = async (): Promise<AdminConfig[]> => {
return (await api.post("/configs/admin/finishSetup")).data; return (await api.post("/configs/admin/finishSetup")).data;
}; };
const sendTestEmail = async (email: string) => {
await api.post("/configs/admin/testEmail", { email });
};
export default { export default {
list, list,
listForAdmin, listForAdmin,
update, updateMany,
get, get,
finishSetup, finishSetup,
sendTestEmail,
}; };

View File

@@ -1,19 +1,22 @@
import { import {
CreateShare,
MyShare, MyShare,
Share, Share,
ShareMetaData, ShareMetaData,
ShareSecurity,
} from "../types/share.type"; } from "../types/share.type";
import api from "./api.service"; import api from "./api.service";
const create = async ( const create = async (share: CreateShare) => {
id: string, const { id, expiration, recipients, security, description } = share;
expiration: string, return (
recipients: string[], await api.post("shares", {
security?: ShareSecurity id,
) => { expiration,
return (await api.post("shares", { id, expiration, recipients, security })) recipients,
.data; security,
description,
})
).data;
}; };
const completeShare = async (id: string) => { const completeShare = async (id: string) => {

View File

@@ -4,11 +4,29 @@ type Config = {
type: string; type: string;
}; };
export type UpdateConfig = {
key: string;
value: string;
};
export type AdminConfig = Config & { export type AdminConfig = Config & {
updatedAt: Date; updatedAt: Date;
secret: boolean; secret: boolean;
description: string; description: string;
obscured: boolean; obscured: boolean;
category: string;
};
export type AdminConfigGroupedByCategory = {
[key: string]: [
Config & {
updatedAt: Date;
secret: boolean;
description: string;
obscured: boolean;
category: string;
}
];
}; };
export default Config; export default Config;

View File

@@ -4,9 +4,18 @@ export type Share = {
id: string; id: string;
files: any; files: any;
creator: User; creator: User;
description?: string;
expiration: Date; expiration: Date;
}; };
export type CreateShare = {
id: string;
description?: string;
recipients: string[];
expiration: string;
security: ShareSecurity;
};
export type ShareMetaData = { export type ShareMetaData = {
id: string; id: string;
isZipReady: boolean; isZipReady: boolean;

View File

@@ -3,6 +3,7 @@ type User = {
username: string; username: string;
email: string; email: string;
isAdmin: boolean; isAdmin: boolean;
totpVerified: boolean;
}; };
export type CreateUser = { export type CreateUser = {
@@ -26,4 +27,9 @@ export type UpdateCurrentUser = {
export type CurrentUser = User & {}; export type CurrentUser = User & {};
export type UserHook = {
user: CurrentUser | null;
setUser: (user: CurrentUser | null) => void;
};
export default User; export default User;

View File

@@ -0,0 +1,10 @@
export const configVariableToFriendlyName = (variable: string) => {
return variable
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
};
export const capitalizeFirstLetter = (string: string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
};

View File

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