Compare commits

...

17 Commits

Author SHA1 Message Date
Elias Schneider
54f591cd60 release: 0.5.1 2023-01-04 16:02:54 +01:00
Elias Schneider
f836a0a3cd chore: add db migration 2023-01-04 15:58:15 +01:00
Elias Schneider
11174656e4 fix: email configuration updated without restart 2023-01-04 15:30:49 +01:00
Elias Schneider
faea1abcc4 feat: use cookies for authentication 2023-01-04 11:54:28 +01:00
Elias Schneider
71658ad39d feat: show version and show button if new release is available on admin page 2022-12-30 19:23:17 +01:00
Elias Schneider
167f0f8c7a chore: improve release scripts 2022-12-30 18:59:05 +01:00
Elias Schneider
85551dc3d3 release: 0.5.0 2022-12-30 14:41:23 +01:00
Elias Schneider
5bc4f902f6 feat: improve config UI (#69)
* add first concept

* completed configuration ui update

* add button for testing email configuration

* improve mobile layout

* add migration

* run formatter

* delete unnecessary modal

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

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

* Enable/disable totp working

* Added the new login procedure including TOTP! :)

* misc: Changed bad description for the TOTP_SECRET env var

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

* fix: refresh user context instead refreshing the page

* refactor: simplify totp error handling

* Removed U2F tab + format schema

* fix: tokens not saved in cookies

* refactor: deleted commented out code

* refactor: move password text to input description

* refactor: remove tabler icon package

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

View File

@@ -1,3 +1,38 @@
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
### Features
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
### Bug Fixes
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
### Features
* custom mail subject ([cabaee5](https://github.com/stonith404/pingvin-share/commit/cabaee588b50877872d210c870bfb9c95b541921))
* improve config UI ([#69](https://github.com/stonith404/pingvin-share/issues/69)) ([5bc4f90](https://github.com/stonith404/pingvin-share/commit/5bc4f902f6218a09423491404806a4b7fb865c98))
* manually switch color scheme ([ef21bac](https://github.com/stonith404/pingvin-share/commit/ef21bac59b11dc68649ab3b195dcb89d2b192e7b))
### Bug Fixes
* refresh token gets deleted on session end ([e5b50f8](https://github.com/stonith404/pingvin-share/commit/e5b50f855c02aa4b5c9ee873dd5a7ab25759972d))
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
### Features
* custom email message ([0616a68](https://github.com/stonith404/pingvin-share/commit/0616a68bd2e0c9cb559ebdf294e353dd3f69c9a5))
* TOTP (two-factor) Authentication ([#55](https://github.com/stonith404/pingvin-share/issues/55)) ([16480f6](https://github.com/stonith404/pingvin-share/commit/16480f6e9572011fadeb981a388b92cb646fa6d9))
### [0.3.6](https://github.com/stonith404/pingvin-share/compare/v0.3.5...v0.3.6) (2022-12-13)

View File

@@ -1,12 +1,12 @@
{
"name": "pingvin-share-backend",
"version": "0.0.1",
"version": "0.5.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pingvin-share-backend",
"version": "0.0.1",
"version": "0.5.1",
"dependencies": {
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
@@ -17,28 +17,33 @@
"@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0",
"@nestjs/throttler": "^3.1.0",
"@prisma/client": "^4.7.1",
"archiver": "^5.3.1",
"argon2": "^0.30.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.0",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.6.0"
"rxjs": "^7.6.0",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1",
"@prisma/client": "^4.7.1",
"@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1",
@@ -46,6 +51,7 @@
"@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7",
"@types/qrcode-svg": "^1.1.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
@@ -58,7 +64,6 @@
"prisma": "^4.7.1",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.1.1",
"typescript": "^4.9.3",
"wait-on": "^6.0.1"
@@ -328,7 +333,6 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
@@ -340,7 +344,6 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@@ -443,7 +446,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
@@ -484,8 +486,7 @@
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
"dev": true
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.15",
@@ -975,6 +976,48 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -1013,7 +1056,6 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz",
"integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c"
@@ -1034,14 +1076,13 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz",
"integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==",
"dev": true,
"devOptional": true,
"hasInstallScript": true
},
"node_modules/@prisma/engines-version": {
"version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz",
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==",
"dev": true
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q=="
},
"node_modules/@sideway/address": {
"version": "4.1.4",
@@ -1067,26 +1108,22 @@
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"dev": true
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA=="
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="
},
"node_modules/@tsconfig/node16": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz",
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==",
"dev": true
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ=="
},
"node_modules/@types/archiver": {
"version": "5.3.1",
@@ -1116,6 +1153,15 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@@ -1288,6 +1334,12 @@
"@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": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -1701,7 +1753,6 @@
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1731,7 +1782,6 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -1939,8 +1989,7 @@
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
},
"node_modules/argon2": {
"version": "0.30.2",
@@ -2597,6 +2646,26 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -2674,8 +2743,7 @@
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
},
"node_modules/cron": {
"version": "2.0.0",
@@ -2826,7 +2894,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"engines": {
"node": ">=0.3.1"
}
@@ -4736,8 +4803,7 @@
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
},
"node_modules/md5": {
"version": "2.3.0",
@@ -5320,6 +5386,16 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5857,7 +5933,7 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz",
"integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"dependencies": {
"@prisma/engines": "4.7.1"
@@ -5912,6 +5988,14 @@
"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": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
@@ -6787,6 +6871,14 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"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": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -6892,7 +6984,6 @@
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -7052,7 +7143,6 @@
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7174,8 +7264,7 @@
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="
},
"node_modules/validator": {
"version": "13.7.0",
@@ -7499,7 +7588,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"engines": {
"node": ">=6"
}
@@ -7748,7 +7836,6 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"requires": {
"@jridgewell/trace-mapping": "0.3.9"
},
@@ -7757,7 +7844,6 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"requires": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@@ -7843,8 +7929,7 @@
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"dev": true
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
},
"@jridgewell/set-array": {
"version": "1.1.2",
@@ -7878,8 +7963,7 @@
"@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
"dev": true
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"@jridgewell/trace-mapping": {
"version": "0.3.15",
@@ -8205,6 +8289,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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -8234,7 +8360,6 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.7.1.tgz",
"integrity": "sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==",
"dev": true,
"requires": {
"@prisma/engines-version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c"
}
@@ -8243,13 +8368,12 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.7.1.tgz",
"integrity": "sha512-zWabHosTdLpXXlMefHmnouhXMoTB1+SCbUU3t4FCmdrtIOZcarPKU3Alto7gm/pZ9vHlGOXHCfVZ1G7OIrSbog==",
"dev": true
"devOptional": true
},
"@prisma/engines-version": {
"version": "4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.7.1-1.272861e07ab64f234d3ffc4094e32bd61775599c.tgz",
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q==",
"dev": true
"integrity": "sha512-Bd4LZ+WAnUHOq31e9X/ihi5zPlr4SzTRwUZZYxvWOxlerIZ7HJlVa9zXpuKTKLpI9O1l8Ec4OYCKsivWCs5a3Q=="
},
"@sideway/address": {
"version": "4.1.4",
@@ -8275,26 +8399,22 @@
"@tsconfig/node10": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"dev": true
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA=="
},
"@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="
},
"@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="
},
"@tsconfig/node16": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz",
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==",
"dev": true
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ=="
},
"@types/archiver": {
"version": "5.3.1",
@@ -8324,6 +8444,15 @@
"@types/node": "*"
}
},
"@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/cookiejar": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@@ -8496,6 +8625,12 @@
"@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": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@@ -8816,8 +8951,7 @@
"acorn": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
"dev": true
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="
},
"acorn-import-assertions": {
"version": "1.8.0",
@@ -8836,8 +8970,7 @@
"acorn-walk": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
"dev": true
"integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA=="
},
"agent-base": {
"version": "6.0.2",
@@ -8991,8 +9124,7 @@
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
},
"argon2": {
"version": "0.30.2",
@@ -9478,6 +9610,22 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
},
"cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"requires": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"dependencies": {
"cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
}
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -9539,8 +9687,7 @@
"create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
},
"cron": {
"version": "2.0.0",
@@ -9648,8 +9795,7 @@
"diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="
},
"dir-glob": {
"version": "3.0.1",
@@ -11123,8 +11269,7 @@
"make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
},
"md5": {
"version": "2.3.0",
@@ -11563,6 +11708,16 @@
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -11959,7 +12114,7 @@
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-4.7.1.tgz",
"integrity": "sha512-CCQP+m+1qZOGIZlvnL6T3ZwaU0LAleIHYFPN9tFSzjs/KL6vH9rlYbGOkTuG9Q1s6Ki5D0LJlYlW18Z9EBUpGg==",
"dev": true,
"devOptional": true,
"requires": {
"@prisma/engines": "4.7.1"
}
@@ -12000,6 +12155,11 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"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": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
@@ -12645,6 +12805,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"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": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -12724,7 +12889,6 @@
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",
"integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==",
"dev": true,
"requires": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -12835,8 +12999,7 @@
"typescript": {
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA=="
},
"uglify-js": {
"version": "3.17.3",
@@ -12916,8 +13079,7 @@
"v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="
},
"validator": {
"version": "13.7.0",
@@ -13157,8 +13319,7 @@
"yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="
},
"yocto-queue": {
"version": "0.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-backend",
"version": "0.0.1",
"version": "0.5.1",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
@@ -28,13 +28,16 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.0",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.6.0",
@@ -45,6 +48,7 @@
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1",
@@ -52,6 +56,7 @@
"@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7",
"@types/qrcode-svg": "^1.1.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",

View File

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

View File

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

View File

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

View File

@@ -19,10 +19,16 @@ model User {
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpSecret String?
}
model RefreshToken {
token String @id @default(uuid())
id String @id @default(uuid())
token String @unique @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
@@ -31,6 +37,17 @@ model RefreshToken {
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 {
id String @id @default(uuid())
createdAt DateTime @default(now())
@@ -85,6 +102,7 @@ model Config {
type String
value String
description String
category String
obscured Boolean @default(false)
secret Boolean @default(true)
locked Boolean @default(false)

View File

@@ -7,6 +7,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether the setup has been finished",
type: "boolean",
value: "false",
category: "internal",
secret: false,
locked: true,
},
@@ -15,6 +16,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
category: "general",
secret: false,
},
{
@@ -22,6 +24,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether to show the home page",
type: "boolean",
value: "true",
category: "general",
secret: false,
},
{
@@ -29,6 +32,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether registration is allowed",
type: "boolean",
value: "true",
category: "share",
secret: false,
},
{
@@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
@@ -43,6 +48,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Maximum file size in bytes",
type: "number",
value: "1000000000",
category: "share",
secret: false,
},
{
@@ -50,6 +56,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
description: "Long random string used to sign JWT tokens",
type: "string",
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,
},
{
@@ -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.",
type: "boolean",
value: "false",
category: "email",
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",
description: "Host of the SMTP server",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_PORT",
description: "Port of the SMTP server",
type: "number",
value: "",
value: "0",
category: "email",
},
{
key: "SMTP_EMAIL",
description: "Email address which the emails get sent from",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_PASSWORD",
@@ -90,6 +126,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
type: "string",
value: "",
obscured: true,
category: "email",
},
];

View File

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

View File

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

View File

@@ -34,8 +34,10 @@ export class AuthService {
},
});
const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
} catch (e) {
@@ -63,14 +65,24 @@ export class AuthService {
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
// 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 { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
}
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");
const hash = await argon.hash(newPassword);
@@ -81,11 +93,12 @@ export class AuthService {
});
}
async createAccessToken(user: User) {
async createAccessToken(user: User, refreshTokenId: string) {
return this.jwtService.sign(
{
sub: user.id,
email: user.email,
refreshTokenId,
},
{
expiresIn: "15min",
@@ -94,6 +107,14 @@ export class AuthService {
);
}
async signOut(accessToken: string) {
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
refreshTokenId: string;
};
await this.prisma.refreshToken.delete({ where: { id: refreshTokenId } });
}
async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
@@ -103,16 +124,27 @@ export class AuthService {
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
throw new UnauthorizedException();
return this.createAccessToken(refreshTokenMetaData.user);
return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
);
}
async createRefreshToken(userId: string) {
const refreshToken = (
await this.prisma.refreshToken.create({
data: { userId, expiresAt: moment().add(3, "months").toDate() },
const { id, token } = await this.prisma.refreshToken.create({
data: { userId, expiresAt: moment().add(3, "months").toDate() },
});
return { refreshTokenId: id, refreshToken: token };
}
async createLoginToken(userId: string) {
const loginToken = (
await this.prisma.loginToken.create({
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
})
).token;
return refreshToken;
return loginToken;
}
}

View File

@@ -0,0 +1,230 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import * as argon from "argon2";
import * as crypto from "crypto";
import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable()
export class AuthTotpService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private authService: AuthService
) {}
async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({
where: {
token: dto.loginToken,
},
});
if (!token || token.userId != user.id || token.used)
throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired");
// Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
const expected = authenticator.generate(decryptedSecret);
if (dto.totp !== expected) {
throw new BadRequestException("Invalid code");
}
// Set the login token to used
await this.prisma.loginToken.update({
where: { token: token.token },
data: { used: true },
});
const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken(
user,
refreshTokenId
);
return { accessToken, refreshToken };
}
encryptTotpSecret(totpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(totpSecret);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString("base64");
}
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
// Check if we have a secret already
const { totpVerified } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpVerified: true },
});
if (totpVerified) {
throw new BadRequestException("TOTP is already enabled");
}
// TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret();
const encryptedSecret = this.encryptTotpSecret(secret, password);
const otpURL = totp.keyuri(
user.username || user.email,
"pingvin-share",
secret
);
await this.prisma.user.update({
where: { id: user.id },
data: {
totpEnabled: true,
totpSecret: encryptedSecret,
},
});
// TODO: Maybe we should generate the QR code on the client rather than the server?
const qrCode = new qrcode({
content: otpURL,
container: "svg-viewbox",
join: true,
}).svg();
return {
totpAuthUrl: otpURL,
totpSecret: secret,
qrCode:
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
};
}
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
async verifyTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not in progress");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: true,
},
});
return true;
}
async disableTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: false,
totpEnabled: false,
totpSecret: null,
},
});
return true;
}
}

View File

@@ -0,0 +1,21 @@
import { PickType } from "@nestjs/mapped-types";
import { IsEmail, IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInTotpDTO extends PickType(UserDTO, [
"password",
] as const) {
@IsEmail()
@IsOptional()
email: string;
@IsString()
@IsOptional()
username: string;
@IsString()
totp: string;
@IsString()
loginToken: string;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,19 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service";
import { ConfigService } from "./config.service";
import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto";
import { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto";
@Controller("configs")
export class ConfigController {
constructor(private configService: ConfigService) {}
constructor(
private configService: ConfigService,
private emailService: EmailService
) {}
@Get()
async list() {
@@ -31,12 +28,10 @@ export class ConfigController {
);
}
@Patch("admin/:key")
@Patch("admin")
@UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
return new AdminConfigDTO().from(
await this.configService.update(key, data.value)
);
async updateMany(@Body() data: UpdateConfigDTO[]) {
await this.configService.updateMany(data);
}
@Post("admin/finishSetup")
@@ -44,4 +39,10 @@ export class ConfigController {
async 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 { EmailModule } from "src/email/email.module";
import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service";
@Global()
@Module({
imports: [EmailModule],
providers: [
{
provide: "CONFIG_VARIABLES",

View File

@@ -23,7 +23,8 @@ export class ConfigService {
if (configVariable.type == "number") return parseInt(configVariable.value);
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() {
@@ -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) {
const configVariable = await this.prisma.config.findUnique({
where: { key },
@@ -46,10 +55,15 @@ export class ConfigService {
if (!configVariable || configVariable.locked)
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(
`Config variable must be of type ${configVariable.type}`
);
}
const updatedVariable = await this.prisma.config.update({
where: { key },

View File

@@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO {
@Expose()
obscured: boolean;
@Expose()
category: string;
from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, {
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 {
@IsString()
key: string;
@IsNotEmpty()
@ValidateIf((dto) => dto.value !== "")
value: string | number | boolean;

View File

@@ -7,9 +7,8 @@ import { ConfigService } from "src/config/config.service";
export class EmailService {
constructor(private config: ConfigService) {}
async sendMail(recipientEmail: string, shareId: string, creator: User) {
// create reusable transporter object using the default SMTP transport
const transporter = nodemailer.createTransport({
getTransporter() {
return nodemailer.createTransport({
host: this.config.get("SMTP_HOST"),
port: parseInt(this.config.get("SMTP_PORT")),
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
@@ -18,17 +17,32 @@ export class EmailService {
pass: this.config.get("SMTP_PASSWORD"),
},
});
}
async sendMail(recipientEmail: string, shareId: string, creator: User) {
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
await transporter.sendMail({
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Files shared with you",
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}\nShared securely with Pingvin Share 🐧`,
subject: this.config.get("EMAIL_SUBJECT"),
text: this.config
.get("EMAIL_MESSAGE")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator.username)
.replaceAll("{shareUrl}", shareUrl),
});
}
async sendTestMail(recipientEmail: string) {
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
});
}
}

View File

@@ -45,7 +45,6 @@ export class FileController {
@Get(":fileId/download")
@UseGuards(ShareSecurityGuard)
async getFileDownloadUrl(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
@@ -57,16 +56,11 @@ export class FileController {
@Get("zip/download")
@UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
res.set({
"Content-Type": "application/zip",
});
return { url };
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a",
"_postman_id": "4b16228d-41ef-4c6b-8a0b-294a30a4cfc2",
"name": "Pingvin Share Testing",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "17822132"
@@ -18,12 +18,12 @@
"exec": [
"if(pm.response.to.have.status(201)){",
" const token = pm.response.json()[\"accessToken\"]",
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
"",
" // Get user id",
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
" const userId = jwtPayload[\"sub\"]",
" pm.collectionVariables.set(\"USER_ID\", userId)",
"",
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
"}",
""
],
@@ -80,6 +80,7 @@
" pm.expect(responseBody).to.have.property(\"accessToken\")",
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
"});",
"",
""
],
"type": "text/javascript"
@@ -97,7 +98,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"options": {
"raw": {
"language": "json"
@@ -1556,23 +1557,13 @@
]
}
],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{USER_AUTH_TOKEN}}",
"type": "string"
}
]
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
"pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
]
}
},

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "pingvin-share",
"version": "0.0.1",
"name": "pingvin-share-frontend",
"version": "0.5.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "pingvin-share",
"version": "0.0.1",
"name": "pingvin-share-frontend",
"version": "0.5.1",
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0",

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share",
"version": "0.0.1",
"name": "pingvin-share-frontend",
"version": "0.5.1",
"scripts": {
"dev": "next dev",
"build": "next build",

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { showNotification } from "@mantine/notifications";
import Link from "next/link";
import React from "react";
import { TbInfoCircle } from "react-icons/tb";
import * as yup from "yup";
import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service";
@@ -17,16 +20,24 @@ import toast from "../../utils/toast.util";
const SignInForm = () => {
const config = useConfig();
const [showTotp, setShowTotp] = React.useState(false);
const [loginToken, setLoginToken] = React.useState("");
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().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({
initialValues: {
emailOrUsername: "",
password: "",
totp: "",
},
validate: yupResolver(validationSchema),
});
@@ -34,10 +45,41 @@ const SignInForm = () => {
const signIn = (email: string, password: string) => {
authService
.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);
};
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 (
<Container size={420} my={40}>
<Title
@@ -59,9 +101,11 @@ const SignInForm = () => {
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
signIn(values.emailOrUsername, values.password)
)}
onSubmit={form.onSubmit((values) => {
if (showTotp)
signInTotp(values.emailOrUsername, values.password, values.totp);
else signIn(values.emailOrUsername, values.password);
})}
>
<TextInput
label="Email or username"
@@ -74,6 +118,15 @@ const SignInForm = () => {
mt="md"
{...form.getInputProps("password")}
/>
{showTotp && (
<TextInput
variant="filled"
label="Code"
placeholder="******"
mt="md"
{...form.getInputProps("totp")}
/>
)}
<Button fullWidth mt="xl" type="submit">
Sign in
</Button>

View File

@@ -33,16 +33,10 @@ const SignUpForm = () => {
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) => {
authService
.signUp(email, username, password)
.then(() => signIn(email, password))
.then(() => window.location.replace("/"))
.catch(toast.axiosError);
};

View File

@@ -5,7 +5,7 @@ import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
const ActionAvatar = () => {
const user = useUser();
const { user } = useUser();
return (
<Menu position="bottom-start" withinPortal>
@@ -37,7 +37,7 @@ const ActionAvatar = () => {
<Menu.Item
onClick={async () => {
authService.signOut();
await authService.signOut();
}}
icon={<TbDoorExit size={14} />}
>

View File

@@ -107,7 +107,7 @@ const useStyles = createStyles((theme) => ({
}));
const NavBar = () => {
const user = useUser();
const { user } = useUser();
const config = useConfig();
const [opened, toggleOpened] = useDisclosure(false);

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 { 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 = () => {
return useContext(UserContext);

View File

@@ -1,5 +1,6 @@
import {
ColorScheme,
ColorSchemeProvider,
Container,
LoadingOverlay,
MantineProvider,
@@ -11,7 +12,8 @@ import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
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 authService from "../services/auth.service";
import configService from "../services/config.service";
@@ -25,9 +27,8 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme();
const router = useRouter();
const config = useConfig();
const [colorScheme, setColorScheme] = useState<ColorScheme>();
const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
@@ -56,7 +57,11 @@ function App({ Component, pageProps }: AppProps) {
}, [router.asPath]);
useEffect(() => {
setColorScheme(systemTheme);
setColorScheme(
preferences.get("colorScheme") == "system"
? systemTheme
: preferences.get("colorScheme")
);
}, [systemTheme]);
return (
@@ -65,26 +70,31 @@ function App({ Component, pageProps }: AppProps) {
withNormalizeCSS
theme={{ colorScheme, ...globalStyle }}
>
<GlobalStyle />
<NotificationsProvider>
<ModalsProvider>
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={user} >
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />
<Container>
<Component {...pageProps} />
</Container>
</UserContext.Provider>{" "}
</ConfigContext.Provider>
)}
</GlobalLoadingContext.Provider>
</ModalsProvider>
</NotificationsProvider>
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={(value) => setColorScheme(value ?? "light")}
>
<GlobalStyle />
<NotificationsProvider>
<ModalsProvider>
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={{ user, setUser }}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />
<Container>
<Component {...pageProps} />
</Container>
</UserContext.Provider>
</ConfigContext.Provider>
)}
</GlobalLoadingContext.Provider>
</ModalsProvider>
</NotificationsProvider>
</ColorSchemeProvider>
</MantineProvider>
);
}

View File

@@ -6,6 +6,7 @@ import {
Paper,
PasswordInput,
Stack,
Tabs,
Text,
TextInput,
Title,
@@ -13,14 +14,17 @@ import {
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import { Tb2Fa } from "react-icons/tb";
import * as yup from "yup";
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import userService from "../../services/user.service";
import toast from "../../utils/toast.util";
const Account = () => {
const user = useUser();
const { user, setUser } = useUser();
const modals = useModals();
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) {
router.push("/");
return;
@@ -117,31 +151,123 @@ const Account = () => {
</Stack>
</form>
</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" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Security
</Title>
<Tabs defaultValue="totp">
<Tabs.List>
<Tabs.Tab value="totp" icon={<Tb2Fa size={14} />}>
TOTP
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="totp" pt="xs">
{user.totpVerified ? (
<>
<form
onSubmit={disableTotpForm.onSubmit((values) => {
authService
.disableTOTP(values.code, values.password)
.then(() => {
toast.success("Successfully disabled TOTP");
values.password = "";
values.code = "";
refreshUser();
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
description="Enter your current password to disable TOTP"
label="Password"
{...disableTotpForm.getInputProps("password")}
/>
<TextInput
variant="filled"
label="Code"
placeholder="******"
{...disableTotpForm.getInputProps("code")}
/>
<Group position="right">
<Button color="red" type="submit">
Disable
</Button>
</Group>
</Stack>
</form>
</>
) : (
<>
<form
onSubmit={enableTotpForm.onSubmit((values) => {
authService
.enableTOTP(values.password)
.then((result) => {
showEnableTotpModal(modals, refreshUser, {
qrCode: result.qrCode,
secret: result.totpSecret,
password: values.password,
});
values.password = "";
})
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="Password"
description="Enter your current password to start enabling TOTP"
{...enableTotpForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Start</Button>
</Group>
</Stack>
</form>
</>
)}
</Tabs.Panel>
</Tabs>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Color scheme
</Title>
<ThemeSwitcher />
</Paper>
<Center mt={80} mb="lg">
<Stack>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all your
active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Stack>
</Center>
</Container>
);

View File

@@ -28,7 +28,7 @@ const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const router = useRouter();
const user = useUser();
const { user } = useUser();
const [shares, setShares] = useState<MyShare[]>();

View File

@@ -1,5 +1,5 @@
import { Space, Title } from "@mantine/core";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
const AdminConfig = () => {
return (

View File

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

View File

@@ -1,18 +1,15 @@
import { Box, Button, Stack, Text, Title } from "@mantine/core";
import { Box, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import { useState } from "react";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Logo from "../../components/Logo";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import configService from "../../services/config.service";
const Setup = () => {
const router = useRouter();
const config = useConfig();
const user = useUser();
const [isLoading, setIsLoading] = useState(false);
const { user } = useUser();
if (!user) {
router.push("/auth/signUp");
@@ -31,19 +28,6 @@ const Setup = () => {
<Box style={{ width: "100%" }}>
<AdminConfigTable />
</Box>
<Button
loading={isLoading}
onClick={async () => {
setIsLoading(true);
await configService.finishSetup();
setIsLoading(false);
window.location.reload();
}}
mb={70}
mt="lg"
>
Let me in
</Button>
</Stack>
</>
);

View File

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

View File

@@ -6,7 +6,7 @@ import useUser from "../../hooks/user.hook";
const SignUp = () => {
const config = useConfig();
const user = useUser();
const { user } = useUser();
const router = useRouter();
if (user) {
router.replace("/");

View File

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

View File

@@ -23,7 +23,7 @@ const Upload = () => {
const router = useRouter();
const modals = useModals();
const user = useUser();
const { user } = useUser();
const config = useConfig();
const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false);

View File

@@ -1,20 +1,7 @@
import axios, { AxiosError } from "axios";
import { getCookie } from "cookies-next";
import axios from "axios";
const api = axios.create({
baseURL: "/api",
});
api.interceptors.request.use(
(config) => {
const accessToken = getCookie("access_token");
if (accessToken) {
config!.headers!.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
export default api;

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookies } from "cookies-next";
import { getCookie } from "cookies-next";
import * as jose from "jose";
import api from "./api.service";
@@ -11,35 +11,51 @@ const signIn = async (emailOrUsername: string, password: string) => {
...emailOrUsernameBody,
password,
});
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken);
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,
});
return response;
};
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 });
return response;
};
const signOut = () => {
setCookies("access_token", null);
setCookies("refresh_token", null);
const signOut = async () => {
await api.post("/auth/signOut");
window.location.reload();
};
const refreshAccessToken = async () => {
try {
const currentAccessToken = getCookie("access_token") as string;
const accessToken = getCookie("access_token") as string;
if (
currentAccessToken &&
(jose.decodeJwt(currentAccessToken).exp ?? 0) * 1000 <
Date.now() + 2 * 60 * 1000
!accessToken ||
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 < Date.now() + 2 * 60 * 1000
) {
const refreshToken = getCookie("refresh_token");
const response = await api.post("auth/token", { refreshToken });
setCookies("access_token", response.data.accessToken);
await api.post("/auth/token");
}
} catch {
} catch (e) {
console.info("Refresh token invalid or expired");
}
};
@@ -48,10 +64,38 @@ const updatePassword = async (oldPassword: string, password: string) => {
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 {
signIn,
signInTotp,
signUp,
signOut,
refreshAccessToken,
updatePassword,
enableTOTP,
verifyTOTP,
disableTOTP,
};

View File

@@ -1,4 +1,5 @@
import Config, { AdminConfig } from "../types/config.type";
import axios from "axios";
import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
import api from "./api.service";
const list = async (): Promise<Config[]> => {
@@ -9,11 +10,8 @@ const listForAdmin = async (): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data;
};
const update = async (
key: string,
value: string | number | boolean
): Promise<AdminConfig[]> => {
return (await api.patch(`/configs/admin/${key}`, { value })).data;
const updateMany = async (data: UpdateConfig[]): Promise<AdminConfig[]> => {
return (await api.patch("/configs/admin", data)).data;
};
const get = (key: string, configVariables: Config[]): any => {
@@ -27,17 +25,33 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "number") return parseInt(configVariable.value);
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[]> => {
return (await api.post("/configs/admin/finishSetup")).data;
};
const sendTestEmail = async (email: string) => {
await api.post("/configs/admin/testEmail", { email });
};
const isNewReleaseAvailable = async () => {
const response = (
await axios.get(
"https://api.github.com/repos/stonith404/pingvin-share/releases/latest"
)
).data;
return response.tag_name.replace("v", "") != process.env.VERSION;
};
export default {
list,
listForAdmin,
update,
updateMany,
get,
finishSetup,
sendTestEmail,
isNewReleaseAvailable,
};

View File

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

View File

@@ -3,6 +3,7 @@ type User = {
username: string;
email: string;
isAdmin: boolean;
totpVerified: boolean;
};
export type CreateUser = {
@@ -26,4 +27,9 @@ export type UpdateCurrentUser = {
export type CurrentUser = User & {};
export type UserHook = {
user: CurrentUser | null;
setUser: (user: CurrentUser | null) => void;
};
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,12 +1,12 @@
{
"name": "pingvin-share",
"version": "0.3.6",
"version": "0.5.1",
"scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
"release:patch": "npm version patch -m 'release: %s' && git push && git push --tags",
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags",
"release:patch": "cd backend && npm version patch --commit-hooks false && cd ../frontend && npm version patch --commit-hooks false && cd .. && git add . && npm version patch --force -m 'release: %s' && git push && git push --tags",
"release:minor": "cd backend && npm version minor --commit-hooks false && cd ../frontend && npm version minor --commit-hooks false && cd .. && git add . && npm version minor --force -m 'release: %s' && git push && git push --tags",
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
}
}