Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09213a295 | ||
|
|
fc116d65c0 | ||
|
|
76088cc76a | ||
|
|
16b697053a | ||
|
|
349bf475cc | ||
|
|
fccc4cbc02 | ||
|
|
f1b44f87fa | ||
|
|
02e41e2437 | ||
|
|
74e8956106 | ||
|
|
dc9ec429c6 | ||
|
|
653d72bcb9 | ||
|
|
a5bef5d4a4 | ||
|
|
c8ad2225e3 | ||
|
|
72c8081e7c | ||
|
|
f2d4895e50 | ||
|
|
54f591cd60 | ||
|
|
f836a0a3cd | ||
|
|
11174656e4 | ||
|
|
faea1abcc4 | ||
|
|
71658ad39d | ||
|
|
167f0f8c7a | ||
|
|
85551dc3d3 | ||
|
|
5bc4f902f6 | ||
|
|
e5b50f855c | ||
|
|
b73144295b | ||
|
|
ef21bac59b | ||
|
|
cabaee588b | ||
|
|
aac363bb37 | ||
|
|
af71317ec4 | ||
|
|
16480f6e95 | ||
|
|
1a034a1966 | ||
|
|
0616a68bd2 | ||
|
|
bfb47ba6e8 | ||
|
|
c1d87a1c29 | ||
|
|
4c7e161217 | ||
|
|
844c47e129 | ||
|
|
9b0c08d0cd | ||
|
|
37fda220e9 | ||
|
|
3b7f5ddc52 | ||
|
|
8728fa5207 | ||
|
|
c265129dcc | ||
|
|
78dd4a7e2a | ||
|
|
3cad4dd487 | ||
|
|
d1d3462056 | ||
|
|
5b01108777 | ||
|
|
3d1d4d0fc7 | ||
|
|
7c0d62a429 | ||
|
|
d010a8a2d3 | ||
|
|
9798e26872 | ||
|
|
0c10dc674f | ||
|
|
084e911eed | ||
|
|
797f8938ca | ||
|
|
05cbb7b27e | ||
|
|
905bab9c86 | ||
|
|
8e38c5fed7 | ||
|
|
7e877ce9f4 | ||
|
|
b1bfb09dfd | ||
|
|
c8a4521677 | ||
|
|
3c74cc14df | ||
|
|
a165f8ec4d | ||
|
|
d6a88f2a22 | ||
|
|
b8172efd59 | ||
|
|
cbe37c6798 | ||
|
|
a545c44426 | ||
|
|
08a2f60f72 | ||
|
|
907e56af0f | ||
|
|
888a0c5faf | ||
|
|
bfb0d151ea | ||
|
|
1f63f22591 | ||
|
|
a2d5e0f72c | ||
|
|
c0d0f6fa90 | ||
|
|
4a016ed57d | ||
|
|
5ea63fb60b | ||
|
|
57cb683c64 | ||
|
|
783b8c2e91 | ||
|
|
75f57a4e57 | ||
|
|
eb142b75f7 | ||
|
|
90a3c69954 | ||
|
|
50887b000d | ||
|
|
e2527de976 | ||
|
|
b5c7b04fcb | ||
|
|
38f493ac5a | ||
|
|
0499548dd3 | ||
|
|
d4a0f1a4f1 | ||
|
|
c795b988df | ||
|
|
7a3967fd6f | ||
|
|
31b3f6cb2f | ||
|
|
e9526fc039 | ||
|
|
6b0b979414 | ||
|
|
176196bc35 | ||
|
|
e958a83b87 | ||
|
|
63368557c1 | ||
|
|
1dbfe0bbc9 | ||
|
|
b649d8bf8e | ||
|
|
b579b8f330 | ||
|
|
493705e4ef | ||
|
|
1b5e53ff7e | ||
|
|
13f98cc32c | ||
|
|
29b4a825d1 | ||
|
|
53c7457697 | ||
|
|
1abc0f7ef3 | ||
|
|
2c3760e064 | ||
|
|
32eaee4236 | ||
|
|
99492c2ecc | ||
|
|
34db3ae2a9 | ||
|
|
32ad43ae27 | ||
|
|
0efd2d8bf9 | ||
|
|
43299522ee |
11
.env.example
11
.env.example
@@ -1,11 +0,0 @@
|
|||||||
# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables
|
|
||||||
|
|
||||||
# GENERAL
|
|
||||||
APP_URL=http://localhost:3000
|
|
||||||
SHOW_HOME_PAGE=true
|
|
||||||
ALLOW_REGISTRATION=true
|
|
||||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
|
||||||
MAX_FILE_SIZE=1000000000
|
|
||||||
|
|
||||||
# SECURITY
|
|
||||||
JWT_SECRET=long-random-string
|
|
||||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
github: stonith404
|
||||||
45
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: "🐛 Bug Report"
|
||||||
|
description: "Submit a bug report to help us improve"
|
||||||
|
title: "🐛 Bug Report: "
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out our bug report form 🙏
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "👟 Reproduction steps"
|
||||||
|
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||||
|
placeholder: "When I ..."
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "👍 Expected behavior"
|
||||||
|
description: "What did you think would happen?"
|
||||||
|
placeholder: "It should ..."
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "👎 Actual Behavior"
|
||||||
|
description: "What did actually happen? Add screenshots, if applicable."
|
||||||
|
placeholder: "It actually ..."
|
||||||
|
- type: input
|
||||||
|
id: operating-system
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Browser"
|
||||||
|
description: "Which browser do you use?"
|
||||||
|
placeholder: "Firefox"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting, please check if the issues hasn't been raised before.
|
||||||
29
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: 🚀 Feature
|
||||||
|
description: "Submit a proposal for a new feature"
|
||||||
|
title: "🚀 Feature: "
|
||||||
|
labels: [feature]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out our feature request form 🙏
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🔖 Feature description"
|
||||||
|
description: "A clear and concise description of what the feature is."
|
||||||
|
placeholder: "You should add ..."
|
||||||
|
- type: textarea
|
||||||
|
id: pitch
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🎤 Pitch"
|
||||||
|
description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable."
|
||||||
|
placeholder: "In my use-case, ..."
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting, please check if the issues hasn't been raised before.
|
||||||
3
.github/workflows/backend-system-tests.yml
vendored
3
.github/workflows/backend-system-tests.yml
vendored
@@ -17,9 +17,6 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm install
|
run: npm install
|
||||||
- name: Create .env file
|
|
||||||
working-directory: ./backend
|
|
||||||
run: mv .env.example .env
|
|
||||||
- name: Run Server and Test with Newman
|
- name: Run Server and Test with Newman
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run test:system
|
run: npm run test:system
|
||||||
|
|||||||
23
.github/workflows/close_inactive_issues.yml
vendored
Normal file
23
.github/workflows/close_inactive_issues.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Close inactive issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "00 00 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v4
|
||||||
|
with:
|
||||||
|
days-before-issue-stale: 30
|
||||||
|
days-before-issue-close: 14
|
||||||
|
exempt-issue-labels: "feature"
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||||
|
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
182
CHANGELOG.md
182
CHANGELOG.md
@@ -1,3 +1,185 @@
|
|||||||
|
## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ClamAV to scan for malicious files ([76088cc](https://github.com/stonith404/pingvin-share/commit/76088cc76aedae709f06deaee2244efcf6a22bed))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* invalid github release link on admin page ([349bf47](https://github.com/stonith404/pingvin-share/commit/349bf475cc7fc1141dbd2a9bd2f63153c4d5b41b))
|
||||||
|
|
||||||
|
### [0.6.1](https://github.com/stonith404/pingvin-share/compare/v0.6.0...v0.6.1) (2023-01-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* delete all sessions if password was changed ([02e41e2](https://github.com/stonith404/pingvin-share/commit/02e41e243768de34de1bdc8833e83f60db530e55))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* shareUrl uses wrong origin ([f1b44f8](https://github.com/stonith404/pingvin-share/commit/f1b44f87fa64d3b21ca92c9068cb352d0ad51bc0))
|
||||||
|
* update password doesn't work ([74e8956](https://github.com/stonith404/pingvin-share/commit/74e895610642552c98c0015d0f8347735aaed457))
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/stonith404/pingvin-share/compare/v0.5.1...v0.6.0) (2023-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* chunk uploads ([#76](https://github.com/stonith404/pingvin-share/issues/76)) ([653d72b](https://github.com/stonith404/pingvin-share/commit/653d72bcb958268e2f23efae94cccb72faa745af))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* access token refreshes even it is still valid ([c8ad222](https://github.com/stonith404/pingvin-share/commit/c8ad2225e3c9ca79fea494d538b67797fbc7f6ae))
|
||||||
|
* error message typo ([72c8081](https://github.com/stonith404/pingvin-share/commit/72c8081e7c135ab1f600ed7e3d7a0bf03dabde34))
|
||||||
|
* migration for v0.5.1 ([f2d4895](https://github.com/stonith404/pingvin-share/commit/f2d4895e50d3da82cef68858752fb7f6293e7a20))
|
||||||
|
* refresh token expires after 1 day instead of 3 months ([a5bef5d](https://github.com/stonith404/pingvin-share/commit/a5bef5d4a4ae75447ca1f65259c5541edfc87dd8))
|
||||||
|
|
||||||
|
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
|
||||||
|
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom mail subject ([cabaee5](https://github.com/stonith404/pingvin-share/commit/cabaee588b50877872d210c870bfb9c95b541921))
|
||||||
|
* improve config UI ([#69](https://github.com/stonith404/pingvin-share/issues/69)) ([5bc4f90](https://github.com/stonith404/pingvin-share/commit/5bc4f902f6218a09423491404806a4b7fb865c98))
|
||||||
|
* manually switch color scheme ([ef21bac](https://github.com/stonith404/pingvin-share/commit/ef21bac59b11dc68649ab3b195dcb89d2b192e7b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* refresh token gets deleted on session end ([e5b50f8](https://github.com/stonith404/pingvin-share/commit/e5b50f855c02aa4b5c9ee873dd5a7ab25759972d))
|
||||||
|
|
||||||
|
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom email message ([0616a68](https://github.com/stonith404/pingvin-share/commit/0616a68bd2e0c9cb559ebdf294e353dd3f69c9a5))
|
||||||
|
* TOTP (two-factor) Authentication ([#55](https://github.com/stonith404/pingvin-share/issues/55)) ([16480f6](https://github.com/stonith404/pingvin-share/commit/16480f6e9572011fadeb981a388b92cb646fa6d9))
|
||||||
|
|
||||||
|
### [0.3.6](https://github.com/stonith404/pingvin-share/compare/v0.3.5...v0.3.6) (2022-12-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add description field to share ([8728fa5](https://github.com/stonith404/pingvin-share/commit/8728fa5207524e9aee26d68eafe1b6fff367d749))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove dot in email link ([9b0c08d](https://github.com/stonith404/pingvin-share/commit/9b0c08d0cdeeeef217ccba57f593fea9d8858371))
|
||||||
|
* rerange accordion items ([844c47e](https://github.com/stonith404/pingvin-share/commit/844c47e1290fb0f7dedb41a18be59ed5ab83dabc))
|
||||||
|
|
||||||
|
### [0.3.5](https://github.com/stonith404/pingvin-share/compare/v0.3.4...v0.3.5) (2022-12-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* upload 3 files at same time ([d010a8a](https://github.com/stonith404/pingvin-share/commit/d010a8a2d366708b1bb5088e9c1e9f9378d3e023))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* jobs never get executed ([05cbb7b](https://github.com/stonith404/pingvin-share/commit/05cbb7b27ef98a3a80dd9edc318f1dcc9a8bd442))
|
||||||
|
* only create zip if more than one file is in the share ([3d1d4d0](https://github.com/stonith404/pingvin-share/commit/3d1d4d0fc7c0351724387c3721280c334ae94d98))
|
||||||
|
* remove unnecessary port expose ([084e911](https://github.com/stonith404/pingvin-share/commit/084e911eed95eb22fea0bf185803ba32c3eda1a9))
|
||||||
|
* setup wizard table doesn't take full width ([9798e26](https://github.com/stonith404/pingvin-share/commit/9798e26872064edc1049138cf73479b1354a43ed))
|
||||||
|
* use node slim to fix arm builds ([797f893](https://github.com/stonith404/pingvin-share/commit/797f8938cac9cc3bb788f632d97eba5c49fe98a5))
|
||||||
|
* zip doesn't contain file extension ([5b01108](https://github.com/stonith404/pingvin-share/commit/5b0110877745f1fcde4952737a93c07ef4a2a92d))
|
||||||
|
|
||||||
|
### [0.3.4](https://github.com/stonith404/pingvin-share/compare/v0.3.3...v0.3.4) (2022-12-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show alternative to copy button if site is not using https ([7e877ce](https://github.com/stonith404/pingvin-share/commit/7e877ce9f4b82d61c9b238e17def9f4c29e7aeb8))
|
||||||
|
* sign up page available when registration is disabled ([c8a4521](https://github.com/stonith404/pingvin-share/commit/c8a4521677280d6aba89d293a1fe0c38adf9f92c))
|
||||||
|
* tables on mobile ([b1bfb09](https://github.com/stonith404/pingvin-share/commit/b1bfb09dfd5c90cc18847470a9ce1ce8397c1476))
|
||||||
|
|
||||||
|
### [0.3.3](https://github.com/stonith404/pingvin-share/compare/v0.3.2...v0.3.3) (2022-12-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add support for different email and user ([888a0c5](https://github.com/stonith404/pingvin-share/commit/888a0c5fafc51b6872ed71e37d4b40c9bf6a07f1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow empty strings in config variable ([b8172ef](https://github.com/stonith404/pingvin-share/commit/b8172efd59fb3271ab9b818b13a7003342b2cebd))
|
||||||
|
* improve admin dashboard color and layout ([a545c44](https://github.com/stonith404/pingvin-share/commit/a545c444261c90105dcb165ebcf4b26634e729ca))
|
||||||
|
* obscure critical config variables ([bfb0d15](https://github.com/stonith404/pingvin-share/commit/bfb0d151ea2ba125e536a16b1873e143a67e9f64))
|
||||||
|
* obscured text length ([cbe37c6](https://github.com/stonith404/pingvin-share/commit/cbe37c679853ecef1522ed213e4cac5defd5b45a))
|
||||||
|
* space character in email ([907e56a](https://github.com/stonith404/pingvin-share/commit/907e56af0faccdbc8d7f5ab3418a4ad71ff849f5))
|
||||||
|
|
||||||
|
### [0.3.2](https://github.com/stonith404/pingvin-share/compare/v0.3.1...v0.3.2) (2022-12-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* make share password optional ([57cb683](https://github.com/stonith404/pingvin-share/commit/57cb683c64eaedec2697ea6863948bd2ae68dd75))
|
||||||
|
* unauthenticated dialog not shown ([4a016ed](https://github.com/stonith404/pingvin-share/commit/4a016ed57db526ee900c567f7b7f0991f948c631))
|
||||||
|
* use session storage for share token ([5ea63fb](https://github.com/stonith404/pingvin-share/commit/5ea63fb60be0c508c38ba228cc8ac6dd7b403aac))
|
||||||
|
|
||||||
|
### [0.3.1](https://github.com/stonith404/pingvin-share/compare/v0.3.0...v0.3.1) (2022-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* dropzone rejection on chrome ([75f57a4](https://github.com/stonith404/pingvin-share/commit/75f57a4e57fb13cc62e87428e8302b453ea6b44b))
|
||||||
|
|
||||||
|
## [0.3.0](https://github.com/stonith404/pingvin-share/compare/v0.2.0...v0.3.0) (2022-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add add new config strategy to frontend ([493705e](https://github.com/stonith404/pingvin-share/commit/493705e4ef21cb638620b0037b9ff2cec8046c95))
|
||||||
|
* add administrator guard ([13f98cc](https://github.com/stonith404/pingvin-share/commit/13f98cc32c804c786c71b10dc4cf029d7795be76))
|
||||||
|
* add job that deleted temporary files ([b649d8b](https://github.com/stonith404/pingvin-share/commit/b649d8bf8e849aff3f350e3c5fd0151a063b9706))
|
||||||
|
* add new config strategy to backend ([1b5e53f](https://github.com/stonith404/pingvin-share/commit/1b5e53ff7ee00228eda6dc5c62d5cd8c3752b03b))
|
||||||
|
* add setup wizard ([b579b8f](https://github.com/stonith404/pingvin-share/commit/b579b8f3309e2d7070e6a82c5da76ab8029bee11))
|
||||||
|
* add user management ([7a3967f](https://github.com/stonith404/pingvin-share/commit/7a3967fd6f76a03461d05e962e82fe5130528ca5))
|
||||||
|
* add user operations to backend ([31b3f6c](https://github.com/stonith404/pingvin-share/commit/31b3f6cb2fc662623df92cdbaf803f1b98a696ae))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* convert async function to sync function ([1dbfe0b](https://github.com/stonith404/pingvin-share/commit/1dbfe0bbc9821bbee02220484c87cf9fe12fd033))
|
||||||
|
* database migration by adding a username ([e9526fc](https://github.com/stonith404/pingvin-share/commit/e9526fc0390cc8ba70c824370041ea9aaf6f9ef9))
|
||||||
|
* docker build ([e958a83](https://github.com/stonith404/pingvin-share/commit/e958a83b87a452e42fb38c12d4b11d71b2323c2d))
|
||||||
|
* share password validation ([c795b98](https://github.com/stonith404/pingvin-share/commit/c795b988df437c85efb91e0f6f8ec782f38dbe3d))
|
||||||
|
* unable to update user privileges ([d4a0f1a](https://github.com/stonith404/pingvin-share/commit/d4a0f1a4f16b7980fb244a4e582ceeb9bfaff877))
|
||||||
|
|
||||||
|
## [0.2.0](https://github.com/stonith404/pingvin-share/compare/v0.1.1...v0.2.0) (2022-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add email recepients functionality ([32ad43a](https://github.com/stonith404/pingvin-share/commit/32ad43ae27a29b946bfba0040cac7eb158c84553))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add public userDTO to prevent confusion ([0efd2d8](https://github.com/stonith404/pingvin-share/commit/0efd2d8bf96506cf7d7dc2dc3164a8d59009cec7))
|
||||||
|
* email sending when not signed in ([32eaee4](https://github.com/stonith404/pingvin-share/commit/32eaee42363250defa92913c738a2702ba3e2693))
|
||||||
|
* hide and disallow email recipients if disabled ([34db3ae](https://github.com/stonith404/pingvin-share/commit/34db3ae2a997498edaa70404807d0e770dad6edb))
|
||||||
|
|
||||||
### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)
|
### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
||||||
|
|
||||||
|
|
||||||
## Submit a Pull Request
|
## Submit a Pull Request
|
||||||
|
|
||||||
Once you created a issue and you want to create a pull request, follow this guide.
|
Once you created a issue and you want to create a pull request, follow this guide.
|
||||||
|
|
||||||
Branch naming convention is as following
|
Branch naming convention is as following
|
||||||
@@ -74,20 +75,21 @@ The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
|||||||
#### Setup
|
#### Setup
|
||||||
|
|
||||||
1. Open the `backend` folder
|
1. Open the `backend` folder
|
||||||
2. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
|
2. Install the dependencies with `npm install`
|
||||||
3. Install the dependencies with `npm install`
|
3. Push the database schema to the database by running `npx prisma db push`
|
||||||
4. Push the database schema to the database by running `npx prisma db push`
|
4. Seed the database with `npx prisma db seed`
|
||||||
5. Start the backend with `npm run dev`
|
5. Start the backend with `npm run dev`
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
|
|
||||||
1. Start the backend first
|
1. Start the backend first
|
||||||
2. Open the `frontend` folder
|
2. Open the `frontend` folder
|
||||||
3. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
|
3. Install the dependencies with `npm install`
|
||||||
4. Install the dependencies with `npm install`
|
4. Start the frontend with `npm run dev`
|
||||||
5. Start the frontend with `npm run dev`
|
|
||||||
|
|
||||||
You're all set!
|
You're all set!
|
||||||
|
|
||||||
|
|||||||
45
Dockerfile
45
Dockerfile
@@ -1,34 +1,51 @@
|
|||||||
FROM node:18-alpine AS frontend-builder
|
# Using node slim because prisma ORM needs libc for ARM builds
|
||||||
|
|
||||||
|
# Stage 1: on frontend dependency change
|
||||||
|
FROM node:18-slim AS frontend-dependencies
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Stage 2: on frontend change
|
||||||
|
FROM node:18-slim AS frontend-builder
|
||||||
|
WORKDIR /opt/app
|
||||||
COPY ./frontend .
|
COPY ./frontend .
|
||||||
|
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:18 AS backend-builder
|
# Stage 3: on backend dependency change
|
||||||
|
FROM node:18-slim AS backend-dependencies
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY backend/package.json backend/package-lock.json ./
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY ./backend .
|
|
||||||
RUN npx prisma generate
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:18 AS runner
|
# Stage 4:on backend change
|
||||||
|
FROM node:18-slim AS backend-builder
|
||||||
|
RUN apt-get update && apt-get install -y openssl
|
||||||
|
WORKDIR /opt/app
|
||||||
|
COPY ./backend .
|
||||||
|
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build && npm prune --production
|
||||||
|
|
||||||
|
# Stage 5: Final image
|
||||||
|
FROM node:18-slim AS runner
|
||||||
|
ENV NODE_ENV=docker
|
||||||
|
RUN apt-get update && apt-get install -y openssl
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=frontend-builder /opt/app/next.config.js .
|
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
COPY --from=frontend-builder /opt/app/.next ./.next
|
# Automatically leverage output traces to reduce image size
|
||||||
COPY --from=frontend-builder /opt/app/node_modules ./node_modules
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
||||||
|
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
||||||
|
|
||||||
WORKDIR /opt/app/backend
|
WORKDIR /opt/app/backend
|
||||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||||
COPY --from=backend-builder /opt/app/dist ./dist
|
COPY --from=backend-builder /opt/app/dist ./dist
|
||||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||||
COPY --from=backend-builder /opt/app/package.json ./
|
COPY --from=backend-builder /opt/app/package.json ./
|
||||||
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
|
||||||
RUN npm i -g dotenv-cli
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD cd frontend && dotenv node_modules/.bin/next start & cd backend && npm run prod
|
CMD node frontend/server.js & cd backend && npm run prod
|
||||||
51
README.md
51
README.md
@@ -2,47 +2,52 @@
|
|||||||
|
|
||||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||||
|
|
||||||
## 🎪 Showcase
|
|
||||||
|
|
||||||
Demo: https://pingvin-share.dev.eliasschneider.com
|
|
||||||
|
|
||||||
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- Create a share with files that you can access with a link
|
- Create a share with files that you can access with a link
|
||||||
- No file size limit, only your disk will be your limit
|
- No file size limit, only your disk will be your limit
|
||||||
- Set a share expiration
|
- Set a share expiration
|
||||||
- Optionally secure your share with a visitor limit and a password
|
- Optionally secure your share with a visitor limit and a password
|
||||||
- Light & dark mode
|
- Email recepients
|
||||||
|
- ClamAV integration
|
||||||
|
|
||||||
|
## 🐧 Get to know Pingvin Share
|
||||||
|
|
||||||
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
||||||
|
|
||||||
## ⌨️ Setup
|
## ⌨️ Setup
|
||||||
|
|
||||||
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` and `.env.example` file.
|
### Recommended installation
|
||||||
2. Rename the `.env.example` file to `.env` and change the environment variables so that they fit to your environment. If you need help with the environment variables take a look [here](#environment-variables)
|
|
||||||
3. Run `docker-compose up -d`
|
1. Download the `docker-compose.yml` file
|
||||||
|
2. Run `docker-compose up -d`
|
||||||
|
|
||||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
### Environment variables
|
### Integrations
|
||||||
|
|
||||||
| Variable | Description | Possible values |
|
#### ClamAV
|
||||||
| ------------------------------ | ------------------------------------------------------------------------------------------- | --------------- |
|
|
||||||
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
|
With ClamAV the shares get scanned for malicious files and get removed if any found.
|
||||||
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
|
|
||||||
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
|
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||||
| `ALLOW_UNAUTHENTICATED_SHARES` | Whether a user can create a share without being signed in. | true/false |
|
2. As soon as the ClamAV container is ready (when ClamAV logs "socket found, clamd started"), restart the Pingvin Share container with `docker compose restart pingvin-share`
|
||||||
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
|
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||||
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
|
|
||||||
|
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
|
### Additional resources
|
||||||
|
|
||||||
|
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
### Upgrade to a new version
|
### Upgrade to a new version
|
||||||
|
|
||||||
1. Check if your local `docker-compose.yml` and `.env` files are up to date with the files in the repository
|
Run `docker compose pull && docker compose up -d` to update your docker container
|
||||||
2. Run `docker compose pull && docker compose up -d` to update your docker container
|
|
||||||
|
|
||||||
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
|
|
||||||
|
|
||||||
## 🖤 Contribute
|
## 🖤 Contribute
|
||||||
|
|
||||||
|
|||||||
7
SECURITY.md
Normal file
7
SECURITY.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# CONFIGURATION
|
|
||||||
APP_URL=http://localhost:3000
|
|
||||||
ALLOW_REGISTRATION=true
|
|
||||||
MAX_FILE_SIZE=5000000000
|
|
||||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
|
||||||
|
|
||||||
# SECURITY
|
|
||||||
JWT_SECRET=random-string
|
|
||||||
2187
backend/package-lock.json
generated
2187
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,77 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "dotenv -- nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"prod": "npx prisma migrate deploy && dotenv node dist/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
"test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
|
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.1.2",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
"@nestjs/core": "^9.1.2",
|
"@nestjs/core": "^9.2.1",
|
||||||
"@nestjs/jwt": "^9.0.0",
|
"@nestjs/jwt": "^10.0.1",
|
||||||
|
"@nestjs/mapped-types": "^1.2.0",
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/passport": "^9.0.0",
|
||||||
"@nestjs/platform-express": "^9.1.2",
|
"@nestjs/platform-express": "^9.2.1",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
"@nestjs/swagger": "^6.1.2",
|
|
||||||
"@nestjs/throttler": "^3.1.0",
|
"@nestjs/throttler": "^3.1.0",
|
||||||
|
"@prisma/client": "^4.8.1",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"argon2": "^0.29.1",
|
"argon2": "^0.30.3",
|
||||||
|
"body-parser": "^1.20.1",
|
||||||
|
"clamscan": "^2.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"multer": "^1.4.5-lts.1",
|
"nodemailer": "^6.9.0",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"qrcode-svg": "^1.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^4.0.4",
|
||||||
"rxjs": "^7.5.7"
|
"rxjs": "^7.8.0",
|
||||||
|
"ts-node": "^10.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.1.4",
|
"@nestjs/cli": "^9.1.8",
|
||||||
"@nestjs/schematics": "^9.0.3",
|
"@nestjs/schematics": "^9.0.4",
|
||||||
"@nestjs/testing": "^9.1.2",
|
"@nestjs/testing": "^9.2.1",
|
||||||
"@prisma/client": "^4.4.0",
|
|
||||||
"@types/archiver": "^5.3.1",
|
"@types/archiver": "^5.3.1",
|
||||||
|
"@types/clamscan": "^2.0.4",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.14",
|
"@types/express": "^4.17.15",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/node": "^18.11.18",
|
||||||
"@types/node": "^18.7.23",
|
"@types/nodemailer": "^6.4.7",
|
||||||
"@types/passport-jwt": "^3.0.7",
|
"@types/passport-jwt": "^3.0.8",
|
||||||
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||||
"@typescript-eslint/parser": "^5.40.0",
|
"@typescript-eslint/parser": "^5.48.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv-cli": "^6.0.0",
|
"eslint": "^8.31.0",
|
||||||
"eslint": "^8.25.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
|
||||||
"newman": "^5.3.2",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.8.2",
|
||||||
"prisma": "^4.4.0",
|
"prisma": "^4.8.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.1",
|
"ts-loader": "^9.4.2",
|
||||||
"ts-node": "^10.9.1",
|
"tsconfig-paths": "4.1.2",
|
||||||
"tsconfig-paths": "4.1.0",
|
"typescript": "^4.9.4",
|
||||||
"typescript": "^4.8.4"
|
"wait-on": "^7.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShareRecipient" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"shareId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ShareRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Share" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"views" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"expiration" DATETIME NOT NULL,
|
||||||
|
"creatorId" TEXT,
|
||||||
|
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
|
||||||
|
DROP TABLE "Share";
|
||||||
|
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Config" (
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"key" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "password", "updatedAt", 'user-' || User.id as "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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- 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,
|
||||||
|
"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", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||||
|
DROP TABLE "Config";
|
||||||
|
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Share" ADD COLUMN "description" TEXT;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
DROP TABLE "RefreshToken";
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;
|
||||||
@@ -12,17 +12,23 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
email String @unique
|
username String @unique
|
||||||
password String
|
email String @unique
|
||||||
firstName String?
|
password String
|
||||||
lastName String?
|
isAdmin Boolean @default(false)
|
||||||
|
|
||||||
shares Share[]
|
shares Share[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
loginTokens LoginToken[]
|
||||||
|
|
||||||
|
totpEnabled Boolean @default(false)
|
||||||
|
totpVerified Boolean @default(false)
|
||||||
|
totpSecret String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
token String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
token String @unique @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
@@ -31,19 +37,41 @@ model RefreshToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LoginToken {
|
||||||
|
token String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
used Boolean @default(false)
|
||||||
|
}
|
||||||
|
|
||||||
model Share {
|
model Share {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
uploadLocked Boolean @default(false)
|
uploadLocked Boolean @default(false)
|
||||||
isZipReady Boolean @default(false)
|
isZipReady Boolean @default(false)
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
expiration DateTime
|
expiration DateTime
|
||||||
|
description String?
|
||||||
|
removedReason String?
|
||||||
|
|
||||||
creatorId String?
|
creatorId String?
|
||||||
creator User? @relation(fields: [creatorId], references: [id])
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
security ShareSecurity?
|
security ShareSecurity?
|
||||||
files File[]
|
recipients ShareRecipient[]
|
||||||
|
files File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShareRecipient {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String
|
||||||
|
|
||||||
|
shareId String
|
||||||
|
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model File {
|
model File {
|
||||||
@@ -67,3 +95,16 @@ model ShareSecurity {
|
|||||||
shareId String? @unique
|
shareId String? @unique
|
||||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Config {
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
key String @id
|
||||||
|
type String
|
||||||
|
value String
|
||||||
|
description String
|
||||||
|
category String
|
||||||
|
obscured Boolean @default(false)
|
||||||
|
secret Boolean @default(true)
|
||||||
|
locked Boolean @default(false)
|
||||||
|
}
|
||||||
|
|||||||
188
backend/prisma/seed/config.seed.ts
Normal file
188
backend/prisma/seed/config.seed.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
|
const configVariables: Prisma.ConfigCreateInput[] = [
|
||||||
|
{
|
||||||
|
key: "SETUP_FINISHED",
|
||||||
|
description: "Whether the setup has been finished",
|
||||||
|
type: "boolean",
|
||||||
|
value: "false",
|
||||||
|
category: "internal",
|
||||||
|
secret: false,
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "APP_URL",
|
||||||
|
description: "On which URL Pingvin Share is available",
|
||||||
|
type: "string",
|
||||||
|
value: "http://localhost:3000",
|
||||||
|
category: "general",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "SHOW_HOME_PAGE",
|
||||||
|
description: "Whether to show the home page",
|
||||||
|
type: "boolean",
|
||||||
|
value: "true",
|
||||||
|
category: "general",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ALLOW_REGISTRATION",
|
||||||
|
description: "Whether registration is allowed",
|
||||||
|
type: "boolean",
|
||||||
|
value: "true",
|
||||||
|
category: "share",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
||||||
|
description: "Whether unauthorized users can create shares",
|
||||||
|
type: "boolean",
|
||||||
|
value: "false",
|
||||||
|
category: "share",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "MAX_SHARE_SIZE",
|
||||||
|
description: "Maximum share size in bytes",
|
||||||
|
type: "number",
|
||||||
|
value: "1073741824",
|
||||||
|
category: "share",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "JWT_SECRET",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ENABLE_EMAIL_RECIPIENTS",
|
||||||
|
description:
|
||||||
|
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||||
|
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: "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",
|
||||||
|
description: "Password of the SMTP server",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
obscured: true,
|
||||||
|
category: "email",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const variable of configVariables) {
|
||||||
|
const existingConfigVariable = await prisma.config.findUnique({
|
||||||
|
where: { key: variable.key },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new config variable if it doesn't exist
|
||||||
|
if (!existingConfigVariable) {
|
||||||
|
await prisma.config.create({
|
||||||
|
data: variable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configVariablesFromDatabase = await prisma.config.findMany();
|
||||||
|
|
||||||
|
// Delete the config variable if it doesn't exist anymore
|
||||||
|
for (const configVariableFromDatabase of configVariablesFromDatabase) {
|
||||||
|
const configVariable = configVariables.find(
|
||||||
|
(v) => v.key == configVariableFromDatabase.key
|
||||||
|
);
|
||||||
|
if (!configVariable) {
|
||||||
|
await prisma.config.delete({
|
||||||
|
where: { key: configVariableFromDatabase.key },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the config variable if the metadata changed
|
||||||
|
} else if (
|
||||||
|
JSON.stringify({
|
||||||
|
...configVariable,
|
||||||
|
key: configVariableFromDatabase.key,
|
||||||
|
value: configVariableFromDatabase.value,
|
||||||
|
}) != JSON.stringify(configVariableFromDatabase)
|
||||||
|
) {
|
||||||
|
await prisma.config.update({
|
||||||
|
where: { key: configVariableFromDatabase.key },
|
||||||
|
data: {
|
||||||
|
...configVariable,
|
||||||
|
key: configVariableFromDatabase.key,
|
||||||
|
value: configVariableFromDatabase.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { JobsService } from "./jobs/jobs.service";
|
|
||||||
|
|
||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { FileController } from "./file/file.controller";
|
import { ConfigModule } from "./config/config.module";
|
||||||
|
import { EmailModule } from "./email/email.module";
|
||||||
import { FileModule } from "./file/file.module";
|
import { FileModule } from "./file/file.module";
|
||||||
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { PrismaService } from "./prisma/prisma.service";
|
|
||||||
import { ShareController } from "./share/share.controller";
|
|
||||||
import { ShareModule } from "./share/share.module";
|
import { ShareModule } from "./share/share.module";
|
||||||
import { UserController } from "./user/user.controller";
|
import { UserModule } from "./user/user.module";
|
||||||
|
import { ClamscanModule } from "./clamscan/clamscan.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ShareModule,
|
ShareModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
|
EmailModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule,
|
||||||
|
JobsModule,
|
||||||
|
UserModule,
|
||||||
ThrottlerModule.forRoot({
|
ThrottlerModule.forRoot({
|
||||||
ttl: 60,
|
ttl: 60,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
ClamscanModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
|
||||||
JobsService,
|
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: ThrottlerGuard,
|
useClass: ThrottlerGuard,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
controllers: [UserController, ShareController, FileController],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -3,43 +3,172 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { Throttle } from "@nestjs/throttler";
|
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 { AuthService } from "./auth.service";
|
||||||
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
|
import { GetUser } from "./decorator/getUser.decorator";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
|
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||||
|
import { TokenDTO } from "./dto/token.dto";
|
||||||
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
|
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||||
|
import { JwtGuard } from "./guard/jwt.guard";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private authTotpService: AuthTotpService,
|
||||||
private config: ConfigService
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
signUp(@Body() dto: AuthRegisterDTO) {
|
async signUp(
|
||||||
if (this.config.get("ALLOW_REGISTRATION") == "false")
|
@Body() dto: AuthRegisterDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
return this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
signIn(@Body() dto: AuthSignInDTO) {
|
async signIn(
|
||||||
return this.authService.signIn(dto);
|
@Body() dto: AuthSignInDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
|
if (result.accessToken && result.refreshToken) {
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
|
@Post("signIn/totp")
|
||||||
|
@HttpCode(200)
|
||||||
|
async signInTotp(
|
||||||
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TokenDTO().from(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("password")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async updatePassword(
|
||||||
|
@GetUser() user: User,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
@Body() dto: UpdatePasswordDTO
|
||||||
|
) {
|
||||||
|
const result = await this.authService.updatePassword(
|
||||||
|
user,
|
||||||
|
dto.oldPassword,
|
||||||
|
dto.password
|
||||||
|
);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(response, result.refreshToken);
|
||||||
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("token")
|
@Post("token")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
async refreshAccessToken(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
body.refreshToken
|
request.cookies.refresh_token
|
||||||
);
|
);
|
||||||
return { accessToken };
|
response.cookie("access_token", accessToken);
|
||||||
|
return new TokenDTO().from({ accessToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("signOut")
|
||||||
|
async signOut(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
|
response.cookie("refresh_token", "", {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("totp/enable")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
|
||||||
|
return this.authTotpService.enableTotp(user, body.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("totp/verify")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||||
|
return this.authTotpService.verifyTotp(user, body.password, body.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("totp/disable")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||||
|
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||||
|
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTokensToResponse(
|
||||||
|
response: Response,
|
||||||
|
refreshToken?: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
if (accessToken) response.cookie("access_token", accessToken);
|
||||||
|
if (refreshToken)
|
||||||
|
response.cookie("refresh_token", refreshToken, {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Module } from "@nestjs/common";
|
|||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({})],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
@@ -27,44 +28,83 @@ export class AuthService {
|
|||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
|
username: dto.username,
|
||||||
password: hash,
|
password: hash,
|
||||||
|
isAdmin: !this.config.get("SETUP_FINISHED"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
user.id
|
||||||
|
);
|
||||||
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PrismaClientKnownRequestError) {
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
throw new BadRequestException("Credentials taken");
|
const duplicatedField: string = e.meta.target[0];
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A user with this ${duplicatedField} already exists`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(dto: AuthSignInDTO) {
|
async signIn(dto: AuthSignInDTO) {
|
||||||
const user = await this.prisma.user.findUnique({
|
if (!dto.email && !dto.username)
|
||||||
|
throw new BadRequestException("Email or username is required");
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: dto.email,
|
OR: [{ email: dto.email }, { username: dto.username }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||||
throw new UnauthorizedException("Wrong email or password");
|
throw new UnauthorizedException("Wrong email or password");
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
// TODO: Make all old loginTokens invalid when a new one is created
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
// Check if the user has TOTP enabled
|
||||||
|
if (user.totpVerified) {
|
||||||
|
const loginToken = await this.createLoginToken(user.id);
|
||||||
|
|
||||||
|
return { loginToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccessToken(user: User) {
|
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||||
|
if (!(await argon.verify(user.password, oldPassword)))
|
||||||
|
throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
|
const hash = await argon.hash(newPassword);
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.createRefreshToken(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessToken(user: User, refreshTokenId: string) {
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
refreshTokenId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
@@ -73,6 +113,19 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signOut(accessToken: string) {
|
||||||
|
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
||||||
|
refreshTokenId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.refreshToken
|
||||||
|
.delete({ where: { id: refreshTokenId } })
|
||||||
|
.catch((e) => {
|
||||||
|
// Ignore error if refresh token doesn't exist
|
||||||
|
if (e.code != "P2025") throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string) {
|
async refreshAccessToken(refreshToken: string) {
|
||||||
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
||||||
where: { token: refreshToken },
|
where: { token: refreshToken },
|
||||||
@@ -82,16 +135,27 @@ export class AuthService {
|
|||||||
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
return this.createAccessToken(refreshTokenMetaData.user);
|
return this.createAccessToken(
|
||||||
|
refreshTokenMetaData.user,
|
||||||
|
refreshTokenMetaData.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string) {
|
||||||
const refreshToken = (
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
await this.prisma.refreshToken.create({
|
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
||||||
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
});
|
||||||
|
|
||||||
|
return { refreshTokenId: id, refreshToken: token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLoginToken(userId: string) {
|
||||||
|
const loginToken = (
|
||||||
|
await this.prisma.loginToken.create({
|
||||||
|
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
|
||||||
})
|
})
|
||||||
).token;
|
).token;
|
||||||
|
|
||||||
return refreshToken;
|
return loginToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
230
backend/src/auth/authTotp.service.ts
Normal file
230
backend/src/auth/authTotp.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class AuthRegisterDTO extends UserDTO {}
|
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
] as const) {}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { PickType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
|
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class AuthSignInDTO extends PickType(UserDTO, [
|
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||||
"email",
|
@IsEmail()
|
||||||
"password",
|
@IsOptional()
|
||||||
] as const) {}
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|||||||
21
backend/src/auth/dto/authSignInTotp.dto.ts
Normal file
21
backend/src/auth/dto/authSignInTotp.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
backend/src/auth/dto/enableTotp.dto.ts
Normal file
4
backend/src/auth/dto/enableTotp.dto.ts
Normal 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) {}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { IsNotEmpty } from "class-validator";
|
|
||||||
|
|
||||||
export class RefreshAccessTokenDTO {
|
|
||||||
@IsNotEmpty()
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
15
backend/src/auth/dto/token.dto.ts
Normal file
15
backend/src/auth/dto/token.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
export class TokenDTO {
|
||||||
|
@Expose()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
refreshToken: string;
|
||||||
|
|
||||||
|
from(partial: Partial<TokenDTO>) {
|
||||||
|
return plainToClass(TokenDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/auth/dto/updatePassword.dto.ts
Normal file
8
backend/src/auth/dto/updatePassword.dto.ts
Normal 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 UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||||
|
@IsString()
|
||||||
|
oldPassword: string;
|
||||||
|
}
|
||||||
8
backend/src/auth/dto/verifyTotp.dto.ts
Normal file
8
backend/src/auth/dto/verifyTotp.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdministratorGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
const { user }: { user: User } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
return user.isAdmin;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
export class JwtGuard extends AuthGuard("jwt") {
|
export class JwtGuard extends AuthGuard("jwt") {
|
||||||
constructor() {
|
constructor(private config: ConfigService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return (await super.canActivate(context)) as boolean;
|
return (await super.canActivate(context)) as boolean;
|
||||||
} catch {
|
} catch {
|
||||||
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true";
|
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { Request } from "express";
|
||||||
|
import { Strategy } from "passport-jwt";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||||
|
config.get("JWT_SECRET");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
secretOrKey: config.get("JWT_SECRET"),
|
secretOrKey: config.get("JWT_SECRET"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static extractJWT(req: Request) {
|
||||||
|
if (!req.cookies.access_token) return null;
|
||||||
|
return req.cookies.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
async validate(payload: { sub: string }) {
|
async validate(payload: { sub: string }) {
|
||||||
const user: User = await this.prisma.user.findUnique({
|
const user: User = await this.prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
backend/src/clamscan/clamscan.module.ts
Normal file
10
backend/src/clamscan/clamscan.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ClamScanService } from "./clamscan.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => FileModule)],
|
||||||
|
providers: [ClamScanService],
|
||||||
|
exports: [ClamScanService],
|
||||||
|
})
|
||||||
|
export class ClamscanModule {}
|
||||||
86
backend/src/clamscan/clamscan.service.ts
Normal file
86
backend/src/clamscan/clamscan.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import * as NodeClam from "clamscan";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { FileService } from "src/file/file.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
const clamscanConfig = {
|
||||||
|
clamdscan: {
|
||||||
|
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
||||||
|
port: 3310,
|
||||||
|
localFallback: false,
|
||||||
|
},
|
||||||
|
preference: "clamdscan",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClamScanService {
|
||||||
|
constructor(
|
||||||
|
private fileService: FileService,
|
||||||
|
private prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||||
|
.init(clamscanConfig)
|
||||||
|
.then((res) => {
|
||||||
|
console.log("ClamAV is active");
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log("ClamAV is not active");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async check(shareId: string) {
|
||||||
|
const clamScan = await this.ClamScan;
|
||||||
|
|
||||||
|
if (!clamScan) return [];
|
||||||
|
|
||||||
|
const infectedFiles = [];
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(`./data/uploads/shares/${shareId}`)
|
||||||
|
.filter((file) => file != "archive.zip");
|
||||||
|
|
||||||
|
for (const fileId of files) {
|
||||||
|
const { isInfected } = await clamScan
|
||||||
|
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
||||||
|
.catch(() => {
|
||||||
|
console.log("ClamAV is not active");
|
||||||
|
return { isInfected: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = (
|
||||||
|
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||||
|
).name;
|
||||||
|
|
||||||
|
if (isInfected) {
|
||||||
|
infectedFiles.push({ id: fileId, name: fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return infectedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndRemove(shareId: string) {
|
||||||
|
const infectedFiles = await this.check(shareId);
|
||||||
|
|
||||||
|
if (infectedFiles.length > 0) {
|
||||||
|
await this.fileService.deleteAllFiles(shareId);
|
||||||
|
await this.prisma.file.deleteMany({ where: { shareId } });
|
||||||
|
|
||||||
|
const fileNames = infectedFiles.map((file) => file.name).join(", ");
|
||||||
|
|
||||||
|
await this.prisma.share.update({
|
||||||
|
where: { id: shareId },
|
||||||
|
data: {
|
||||||
|
removedReason: `Your share got removed because the file(s) ${fileNames} are malicious.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/config/config.controller.ts
Normal file
48
backend/src/config/config.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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,
|
||||||
|
private emailService: EmailService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return new ConfigDTO().fromList(await this.configService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("admin")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async listForAdmin() {
|
||||||
|
return new AdminConfigDTO().fromList(
|
||||||
|
await this.configService.listForAdmin()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("admin")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
|
await this.configService.updateMany(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("admin/finishSetup")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async finishSetup() {
|
||||||
|
return await this.configService.finishSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("admin/testEmail")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||||
|
await this.emailService.sendTestMail(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/config/config.module.ts
Normal file
23
backend/src/config/config.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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",
|
||||||
|
useFactory: async (prisma: PrismaService) => {
|
||||||
|
return await prisma.config.findMany();
|
||||||
|
},
|
||||||
|
inject: [PrismaService],
|
||||||
|
},
|
||||||
|
ConfigService,
|
||||||
|
],
|
||||||
|
controllers: [ConfigController],
|
||||||
|
exports: [ConfigService],
|
||||||
|
})
|
||||||
|
export class ConfigModule {}
|
||||||
84
backend/src/config/config.service.ts
Normal file
84
backend/src/config/config.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Config } from "@prisma/client";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConfigService {
|
||||||
|
constructor(
|
||||||
|
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||||
|
private prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get(key: string): any {
|
||||||
|
const configVariable = this.configVariables.filter(
|
||||||
|
(variable) => variable.key == key
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||||
|
|
||||||
|
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||||
|
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||||
|
if (configVariable.type == "string" || configVariable.type == "text")
|
||||||
|
return configVariable.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForAdmin() {
|
||||||
|
return await this.prisma.config.findMany({
|
||||||
|
where: { locked: { equals: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
return await this.prisma.config.findMany({
|
||||||
|
where: { secret: { equals: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configVariable || configVariable.locked)
|
||||||
|
throw new NotFoundException("Config variable not found");
|
||||||
|
|
||||||
|
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 },
|
||||||
|
data: { value: value.toString() },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.configVariables = await this.prisma.config.findMany();
|
||||||
|
|
||||||
|
return updatedVariable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishSetup() {
|
||||||
|
return await this.prisma.config.update({
|
||||||
|
where: { key: "SETUP_FINISHED" },
|
||||||
|
data: { value: "true" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/src/config/dto/adminConfig.dto.ts
Normal file
31
backend/src/config/dto/adminConfig.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
import { ConfigDTO } from "./config.dto";
|
||||||
|
|
||||||
|
export class AdminConfigDTO extends ConfigDTO {
|
||||||
|
@Expose()
|
||||||
|
secret: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
obscured: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
from(partial: Partial<AdminConfigDTO>) {
|
||||||
|
return plainToClass(AdminConfigDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||||
|
return partial.map((part) =>
|
||||||
|
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/config/dto/config.dto.ts
Normal file
18
backend/src/config/dto/config.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
export class ConfigDTO {
|
||||||
|
@Expose()
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
fromList(partial: Partial<ConfigDTO>[]) {
|
||||||
|
return partial.map((part) =>
|
||||||
|
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/config/dto/testEmail.dto.ts
Normal file
7
backend/src/config/dto/testEmail.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEmail, IsNotEmpty } from "class-validator";
|
||||||
|
|
||||||
|
export class TestEmailDTO {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
12
backend/src/config/dto/updateConfig.dto.ts
Normal file
12
backend/src/config/dto/updateConfig.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsNotEmpty, IsString, ValidateIf } from "class-validator";
|
||||||
|
|
||||||
|
class UpdateConfigDTO {
|
||||||
|
@IsString()
|
||||||
|
key: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ValidateIf((dto) => dto.value !== "")
|
||||||
|
value: string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateConfigDTO;
|
||||||
8
backend/src/email/email.module.ts
Normal file
8
backend/src/email/email.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { EmailService } from "./email.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
48
backend/src/email/email.service.ts
Normal file
48
backend/src/email/email.service.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import * as nodemailer from "nodemailer";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
auth: {
|
||||||
|
user: this.config.get("SMTP_USERNAME"),
|
||||||
|
pass: this.config.get("SMTP_PASSWORD"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||||
|
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||||
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
|
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||||
|
|
||||||
|
await this.getTransporter().sendMail({
|
||||||
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: this.config.get("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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseFilePipeBuilder,
|
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
UploadedFile,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import * as contentDisposition from "content-disposition";
|
import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
||||||
import { ShareDTO } from "src/share/dto/share.dto";
|
|
||||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
@@ -25,34 +23,29 @@ export class FileController {
|
|||||||
constructor(private fileService: FileService) {}
|
constructor(private fileService: FileService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@SkipThrottle()
|
||||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||||
@UseInterceptors(
|
|
||||||
FileInterceptor("file", {
|
|
||||||
dest: "./data/uploads/_temp/",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
async create(
|
async create(
|
||||||
@UploadedFile(
|
@Query() query: any,
|
||||||
new ParseFilePipeBuilder()
|
|
||||||
.addMaxSizeValidator({
|
@Body() body: string,
|
||||||
maxSize: parseInt(process.env.MAX_FILE_SIZE),
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
file: Express.Multer.File,
|
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
) {
|
) {
|
||||||
// Fixes file names with special characters
|
const { id, name, chunkIndex, totalChunks } = query;
|
||||||
file.originalname = Buffer.from(file.originalname, "latin1").toString(
|
|
||||||
"utf8"
|
const data = body.toString().split(",")[1];
|
||||||
|
|
||||||
|
return await this.fileService.create(
|
||||||
|
data,
|
||||||
|
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||||
|
{ id, name },
|
||||||
|
shareId
|
||||||
);
|
);
|
||||||
return new ShareDTO().from(await this.fileService.create(file, shareId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId/download")
|
@Get(":fileId/download")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(ShareSecurityGuard)
|
||||||
async getFileDownloadUrl(
|
async getFileDownloadUrl(
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string
|
@Param("fileId") fileId: string
|
||||||
) {
|
) {
|
||||||
@@ -64,16 +57,11 @@ export class FileController {
|
|||||||
@Get("zip/download")
|
@Get("zip/download")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(ShareSecurityGuard)
|
||||||
async getZipArchiveDownloadURL(
|
async getZipArchiveDownloadURL(
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string
|
@Param("fileId") fileId: string
|
||||||
) {
|
) {
|
||||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
||||||
|
|
||||||
res.set({
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
});
|
|
||||||
|
|
||||||
return { url };
|
return { url };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +74,7 @@ export class FileController {
|
|||||||
const zip = this.fileService.getZip(shareId);
|
const zip = this.fileService.getZip(shareId);
|
||||||
res.set({
|
res.set({
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}"`,
|
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(zip);
|
return new StreamableFile(zip);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { randomUUID } from "crypto";
|
import * as crypto from "crypto";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as mime from "mime-types";
|
import * as mime from "mime-types";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -18,32 +20,85 @@ export class FileService {
|
|||||||
private config: ConfigService
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(file: Express.Multer.File, shareId: string) {
|
async create(
|
||||||
|
data: string,
|
||||||
|
chunk: { index: number; total: number },
|
||||||
|
file: { id?: string; name: string },
|
||||||
|
shareId: string
|
||||||
|
) {
|
||||||
|
if (!file.id) file.id = crypto.randomUUID();
|
||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
|
include: { files: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (share.uploadLocked)
|
if (share.uploadLocked)
|
||||||
throw new BadRequestException("Share is already completed");
|
throw new BadRequestException("Share is already completed");
|
||||||
|
|
||||||
const fileId = randomUUID();
|
let diskFileSize: number;
|
||||||
|
try {
|
||||||
|
diskFileSize = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
|
||||||
|
).size;
|
||||||
|
} catch {
|
||||||
|
diskFileSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
await fs.promises.mkdir(`./data/uploads/shares/${shareId}`, {
|
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||||
recursive: true,
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
});
|
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||||
fs.promises.rename(
|
|
||||||
`./data/uploads/_temp/${file.filename}`,
|
if (expectedChunkIndex != chunk.index)
|
||||||
`./data/uploads/shares/${shareId}/${fileId}`
|
throw new BadRequestException({
|
||||||
|
message: "Unexpected chunk received",
|
||||||
|
error: "unexpected_chunk_index",
|
||||||
|
expectedChunkIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(data, "base64");
|
||||||
|
|
||||||
|
// Check if share size limit is exceeded
|
||||||
|
const fileSizeSum = share.files.reduce(
|
||||||
|
(n, { size }) => n + parseInt(size),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.prisma.file.create({
|
if (
|
||||||
data: {
|
fileSizeSum + diskFileSize + buffer.byteLength >
|
||||||
id: fileId,
|
this.config.get("MAX_SHARE_SIZE")
|
||||||
name: file.originalname,
|
) {
|
||||||
size: file.size.toString(),
|
throw new HttpException(
|
||||||
share: { connect: { id: shareId } },
|
"Max share size exceeded",
|
||||||
},
|
HttpStatus.PAYLOAD_TOO_LARGE
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.appendFileSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastChunk = chunk.index == chunk.total - 1;
|
||||||
|
if (isLastChunk) {
|
||||||
|
fs.renameSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}`
|
||||||
|
);
|
||||||
|
const fileSize = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}`
|
||||||
|
).size;
|
||||||
|
await this.prisma.file.create({
|
||||||
|
data: {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
size: fileSize.toString(),
|
||||||
|
share: { connect: { id: shareId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(shareId: string, fileId: string) {
|
async get(shareId: string, fileId: string) {
|
||||||
@@ -80,6 +135,7 @@ export class FileService {
|
|||||||
|
|
||||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
getFileDownloadUrl(shareId: string, fileId: string) {
|
||||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
||||||
|
|
||||||
return `${this.config.get(
|
return `${this.config.get(
|
||||||
"APP_URL"
|
"APP_URL"
|
||||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
||||||
|
|||||||
9
backend/src/jobs/jobs.module.ts
Normal file
9
backend/src/jobs/jobs.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { JobsService } from "./jobs.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [FileModule],
|
||||||
|
providers: [JobsService],
|
||||||
|
})
|
||||||
|
export class JobsModule {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
|
import * as fs from "fs";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
@@ -35,6 +36,38 @@ export class JobsService {
|
|||||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron("0 0 * * *")
|
||||||
|
deleteTemporaryFiles() {
|
||||||
|
let filesDeleted = 0;
|
||||||
|
|
||||||
|
const shareDirectories = fs
|
||||||
|
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
||||||
|
.filter((dirent) => dirent.isDirectory())
|
||||||
|
.map((dirent) => dirent.name);
|
||||||
|
|
||||||
|
for (const shareDirectory of shareDirectories) {
|
||||||
|
const temporaryFiles = fs
|
||||||
|
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
||||||
|
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||||
|
|
||||||
|
for (const file of temporaryFiles) {
|
||||||
|
const stats = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareDirectory}/${file}`
|
||||||
|
);
|
||||||
|
const isOlderThanOneDay = moment(stats.mtime)
|
||||||
|
.add(1, "day")
|
||||||
|
.isBefore(moment());
|
||||||
|
|
||||||
|
if (isOlderThanOneDay) {
|
||||||
|
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
||||||
|
filesDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`job: deleted ${filesDeleted} temporary files`);
|
||||||
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
async deleteExpiredRefreshTokens() {
|
async deleteExpiredRefreshTokens() {
|
||||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import * as bodyParser from "body-parser";
|
||||||
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.useGlobalPipes(new ValidationPipe());
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
|
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||||
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient {
|
export class PrismaService extends PrismaClient {
|
||||||
constructor(config: ConfigService) {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
datasources: {
|
datasources: {
|
||||||
db: {
|
db: {
|
||||||
url: "file:../data/pingvin-share.db",
|
url: "file:../data/pingvin-share.db?connection_limit=1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsString, Length, Matches, ValidateNested } from "class-validator";
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
import { ShareSecurityDTO } from "./shareSecurity.dto";
|
import { ShareSecurityDTO } from "./shareSecurity.dto";
|
||||||
|
|
||||||
export class CreateShareDTO {
|
export class CreateShareDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
|
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
|
||||||
message: "ID only can contain letters, numbers, underscores and hyphens",
|
message: "ID can only contain letters, numbers, underscores and hyphens",
|
||||||
})
|
})
|
||||||
@Length(3, 50)
|
@Length(3, 50)
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +21,13 @@ export class CreateShareDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
expiration: string;
|
expiration: string;
|
||||||
|
|
||||||
|
@MaxLength(512)
|
||||||
|
@IsOptional()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsEmail({}, { each: true })
|
||||||
|
recipients: string[];
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => ShareSecurityDTO)
|
@Type(() => ShareSecurityDTO)
|
||||||
security: ShareSecurityDTO;
|
security: ShareSecurityDTO;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
recipients: string[];
|
||||||
|
|
||||||
from(partial: Partial<MyShareDTO>) {
|
from(partial: Partial<MyShareDTO>) {
|
||||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Expose, plainToClass, Type } from "class-transformer";
|
import { Expose, plainToClass, Type } from "class-transformer";
|
||||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
|
||||||
import { FileDTO } from "src/file/dto/file.dto";
|
import { FileDTO } from "src/file/dto/file.dto";
|
||||||
|
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
|
||||||
|
|
||||||
export class ShareDTO {
|
export class ShareDTO {
|
||||||
@Expose()
|
@Expose()
|
||||||
@@ -14,8 +14,11 @@ export class ShareDTO {
|
|||||||
files: FileDTO[];
|
files: FileDTO[];
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => AuthSignInDTO)
|
@Type(() => PublicUserDTO)
|
||||||
creator: AuthSignInDTO;
|
creator: PublicUserDTO;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
description: string;
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
export class SharePasswordDto {
|
export class SharePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { ClamscanModule } from "src/clamscan/clamscan.module";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { FileModule } from "src/file/file.module";
|
import { FileModule } from "src/file/file.module";
|
||||||
import { ShareController } from "./share.controller";
|
import { ShareController } from "./share.controller";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), forwardRef(() => FileModule)],
|
imports: [
|
||||||
|
JwtModule.register({}),
|
||||||
|
EmailModule,
|
||||||
|
ClamscanModule,
|
||||||
|
forwardRef(() => FileModule),
|
||||||
|
],
|
||||||
controllers: [ShareController],
|
controllers: [ShareController],
|
||||||
providers: [ShareService],
|
providers: [ShareService],
|
||||||
exports: [ShareService],
|
exports: [ShareService],
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Share, User } from "@prisma/client";
|
import { Share, User } from "@prisma/client";
|
||||||
import * as archiver from "archiver";
|
import * as archiver from "archiver";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
|
import { ClamScanService } from "src/clamscan/clamscan.service";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { EmailService } from "src/email/email.service";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
@@ -20,8 +22,10 @@ export class ShareService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
|
private emailService: EmailService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private jwtService: JwtService
|
private jwtService: JwtService,
|
||||||
|
private clasmScanService: ClamScanService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(share: CreateShareDTO, user?: User) {
|
async create(share: CreateShareDTO, user?: User) {
|
||||||
@@ -36,7 +40,7 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We have to add an exception for "never" (since moment won't like that)
|
// We have to add an exception for "never" (since moment won't like that)
|
||||||
let expirationDate;
|
let expirationDate: Date;
|
||||||
if (share.expiration !== "never") {
|
if (share.expiration !== "never") {
|
||||||
expirationDate = moment()
|
expirationDate = moment()
|
||||||
.add(
|
.add(
|
||||||
@@ -54,12 +58,21 @@ export class ShareService {
|
|||||||
expirationDate = moment(0).toDate();
|
expirationDate = moment(0).toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
return await this.prisma.share.create({
|
return await this.prisma.share.create({
|
||||||
data: {
|
data: {
|
||||||
...share,
|
...share,
|
||||||
expiration: expirationDate,
|
expiration: expirationDate,
|
||||||
creator: { connect: user ? { id: user.id } : undefined },
|
creator: { connect: user ? { id: user.id } : undefined },
|
||||||
security: { create: share.security },
|
security: { create: share.security },
|
||||||
|
recipients: {
|
||||||
|
create: share.recipients
|
||||||
|
? share.recipients.map((email) => ({ email }))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,20 +97,36 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async complete(id: string) {
|
async complete(id: string) {
|
||||||
|
const share = await this.prisma.share.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { files: true, recipients: true, creator: true },
|
||||||
|
});
|
||||||
|
|
||||||
if (await this.isShareCompleted(id))
|
if (await this.isShareCompleted(id))
|
||||||
throw new BadRequestException("Share already completed");
|
throw new BadRequestException("Share already completed");
|
||||||
|
|
||||||
const moreThanOneFileInShare =
|
if (share.files.length == 0)
|
||||||
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
|
|
||||||
|
|
||||||
if (!moreThanOneFileInShare)
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You need at least on file in your share to complete it."
|
"You need at least on file in your share to complete it."
|
||||||
);
|
);
|
||||||
|
|
||||||
this.createZip(id).then(() =>
|
// Asynchronously create a zip of all files
|
||||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
if (share.files.length > 1)
|
||||||
);
|
this.createZip(id).then(() =>
|
||||||
|
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send email for each recepient
|
||||||
|
for (const recepient of share.recipients) {
|
||||||
|
await this.emailService.sendMail(
|
||||||
|
recepient.email,
|
||||||
|
share.id,
|
||||||
|
share.creator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any file is malicious with ClamAV
|
||||||
|
this.clasmScanService.checkAndRemove(share.id);
|
||||||
|
|
||||||
return await this.prisma.share.update({
|
return await this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -106,7 +135,7 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSharesByUser(userId: string) {
|
async getSharesByUser(userId: string) {
|
||||||
return await this.prisma.share.findMany({
|
const shares = await this.prisma.share.findMany({
|
||||||
where: {
|
where: {
|
||||||
creator: { id: userId },
|
creator: { id: userId },
|
||||||
uploadLocked: true,
|
uploadLocked: true,
|
||||||
@@ -119,11 +148,21 @@ export class ShareService {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
expiration: "desc",
|
expiration: "desc",
|
||||||
},
|
},
|
||||||
|
include: { recipients: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sharesWithEmailRecipients = shares.map((share) => {
|
||||||
|
return {
|
||||||
|
...share,
|
||||||
|
recipients: share.recipients.map((recipients) => recipients.email),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return sharesWithEmailRecipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
const share: any = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -131,10 +170,13 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (share.removedReason)
|
||||||
|
throw new NotFoundException(share.removedReason, "share_removed");
|
||||||
|
|
||||||
if (!share || !share.uploadLocked)
|
if (!share || !share.uploadLocked)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
return share;
|
return share as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaData(id: string) {
|
async getMetaData(id: string) {
|
||||||
|
|||||||
15
backend/src/user/dto/createUser.dto.ts
Normal file
15
backend/src/user/dto/createUser.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
import { Allow } from "class-validator";
|
||||||
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
|
export class CreateUserDTO extends UserDTO {
|
||||||
|
@Expose()
|
||||||
|
@Allow()
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
from(partial: Partial<CreateUserDTO>) {
|
||||||
|
return plainToClass(CreateUserDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/src/user/dto/publicUser.dto.ts
Normal file
4
backend/src/user/dto/publicUser.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
|
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
||||||
6
backend/src/user/dto/updateOwnUser.dto.ts
Normal file
6
backend/src/user/dto/updateOwnUser.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { OmitType, PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
|
export class UpdateOwnUserDTO extends PartialType(
|
||||||
|
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||||
|
) {}
|
||||||
4
backend/src/user/dto/updateUser.dto.ts
Normal file
4
backend/src/user/dto/updateUser.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateUserDTO } from "./createUser.dto";
|
||||||
|
|
||||||
|
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import { Expose, plainToClass } from "class-transformer";
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
import { IsEmail, Length, Matches, MinLength } from "class-validator";
|
||||||
|
|
||||||
export class UserDTO {
|
export class UserDTO {
|
||||||
@Expose()
|
@Expose()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
firstName: string;
|
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
|
||||||
|
message: "Username can only contain letters, numbers, dots and underscores",
|
||||||
|
})
|
||||||
|
@Length(3, 32)
|
||||||
|
username: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
lastName: string;
|
|
||||||
|
|
||||||
@Expose()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@MinLength(8)
|
||||||
@IsString()
|
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
totpVerified: boolean;
|
||||||
|
|
||||||
from(partial: Partial<UserDTO>) {
|
from(partial: Partial<UserDTO>) {
|
||||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fromList(partial: Partial<UserDTO>[]) {
|
||||||
|
return partial.map((part) =>
|
||||||
|
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,71 @@
|
|||||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
|
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
||||||
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
import { UserDTO } from "./dto/user.dto";
|
||||||
|
import { UserSevice } from "./user.service";
|
||||||
|
|
||||||
@Controller("users")
|
@Controller("users")
|
||||||
export class UserController {
|
export class UserController {
|
||||||
|
constructor(private userService: UserSevice) {}
|
||||||
|
|
||||||
|
// Own user operations
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getCurrentUser(@GetUser() user: User) {
|
async getCurrentUser(@GetUser() user: User) {
|
||||||
return new UserDTO().from(user);
|
return new UserDTO().from(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("me")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async updateCurrentUser(
|
||||||
|
@GetUser() user: User,
|
||||||
|
@Body() data: UpdateOwnUserDTO
|
||||||
|
) {
|
||||||
|
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("me")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
async deleteCurrentUser(@GetUser() user: User) {
|
||||||
|
return new UserDTO().from(await this.userService.delete(user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global user operations
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async list() {
|
||||||
|
return new UserDTO().fromList(await this.userService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async create(@Body() user: CreateUserDTO) {
|
||||||
|
return new UserDTO().from(await this.userService.create(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async update(@Param("id") id: string, @Body() user: UpdateUserDto) {
|
||||||
|
return new UserDTO().from(await this.userService.update(id, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async delete(@Param("id") id: string) {
|
||||||
|
return new UserDTO().from(await this.userService.delete(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
backend/src/user/user.module.ts
Normal file
9
backend/src/user/user.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { UserController } from "./user.controller";
|
||||||
|
import { UserSevice } from "./user.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [UserSevice],
|
||||||
|
controllers: [UserController],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
65
backend/src/user/user.service.ts
Normal file
65
backend/src/user/user.service.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
|
import * as argon from "argon2";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
|
import { UserDTO } from "./dto/user.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserSevice {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
return await this.prisma.user.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
return await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateUserDTO) {
|
||||||
|
const hash = await argon.hash(dto.password);
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
password: hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
|
if (e.code == "P2002") {
|
||||||
|
const duplicatedField: string = e.meta.target[0];
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A user with this ${duplicatedField} already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, user: UpdateUserDto) {
|
||||||
|
try {
|
||||||
|
const hash = user.password && (await argon.hash(user.password));
|
||||||
|
|
||||||
|
return await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...user, password: hash },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
|
if (e.code == "P2002") {
|
||||||
|
const duplicatedField: string = e.meta.target[0];
|
||||||
|
throw new BadRequestException(
|
||||||
|
`A user with this ${duplicatedField} already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
return await this.prisma.user.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "243b0832-3a6a-4389-bb71-4d988c0a86d9",
|
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
|
||||||
"name": "Pingvin Share Testing",
|
"name": "Pingvin Share Testing",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
"_exporter_id": "17822132"
|
"_exporter_id": "17822132"
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
"exec": [
|
"exec": [
|
||||||
"if(pm.response.to.have.status(201)){",
|
"if(pm.response.to.have.status(201)){",
|
||||||
" const token = pm.response.json()[\"accessToken\"]",
|
" const token = pm.response.json()[\"accessToken\"]",
|
||||||
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
|
|
||||||
"",
|
|
||||||
" // Get user id",
|
" // Get user id",
|
||||||
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
||||||
" const userId = jwtPayload[\"sub\"]",
|
" const userId = jwtPayload[\"sub\"]",
|
||||||
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
||||||
|
"",
|
||||||
|
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
|
||||||
"}",
|
"}",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test\",\n \"email\": \"system@test.org\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
|
"raw": "{\n \"email\": \"system@test.org\",\n \"username\": \"system.test\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
||||||
"});",
|
"});",
|
||||||
|
"",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test2\",\n \"email\": \"system2@test.org\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@@ -431,7 +432,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -444,7 +445,7 @@
|
|||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"id\": \"test-share\",\n \"expiration\": \"1-day\",\n \"security\": {\n \"password\": \"share-password\",\n \"maxViews\": 1\n }\n}",
|
"raw": "{\n \"id\": \"test-share\",\n \"expiration\": \"1-day\",\n \"recipients\": [],\n \"security\": {\n \"password\": \"share-password\",\n \"maxViews\": 1\n }\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@@ -477,28 +478,34 @@
|
|||||||
"pm.test(\"Response body correct\", () => {",
|
"pm.test(\"Response body correct\", () => {",
|
||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
" pm.expect(responseBody.name).to.be.equal(\"test-file.txt\")",
|
||||||
|
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
||||||
"});"
|
"});"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disabledSystemHeaders": {
|
||||||
|
"content-type": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/octet-stream",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "formdata",
|
"mode": "raw",
|
||||||
"formdata": [
|
"raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files?name=test-file.txt&chunkIndex=0&totalChunks=1",
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
@@ -507,6 +514,93 @@
|
|||||||
":shareId",
|
":shareId",
|
||||||
"files"
|
"files"
|
||||||
],
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"value": "test-file.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "chunkIndex",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalChunks",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "shareId",
|
||||||
|
"value": "test-share"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Upload file 2",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"pm.test(\"Status code is 201\", () => {",
|
||||||
|
" pm.response.to.have.status(201);",
|
||||||
|
"});",
|
||||||
|
"",
|
||||||
|
"pm.test(\"Response body correct\", () => {",
|
||||||
|
" const responseBody = pm.response.json();",
|
||||||
|
" pm.expect(responseBody.name).to.be.equal(\"test-file2.txt\")",
|
||||||
|
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
||||||
|
"});"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disabledSystemHeaders": {
|
||||||
|
"content-type": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/octet-stream",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{API_URL}}/shares/:shareId/files?name=test-file2.txt&chunkIndex=0&totalChunks=1",
|
||||||
|
"host": [
|
||||||
|
"{{API_URL}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"shares",
|
||||||
|
":shareId",
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"value": "test-file2.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "chunkIndex",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalChunks",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
"key": "shareId",
|
"key": "shareId",
|
||||||
@@ -532,7 +626,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -942,9 +1036,9 @@
|
|||||||
" pm.response.to.have.status(200);",
|
" pm.response.to.have.status(200);",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"pm.test(\"Response contains 1 file\", () => {",
|
"pm.test(\"Response contains 2 files\", () => {",
|
||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody.files.length).be.equal(1)",
|
" pm.expect(responseBody.files.length).be.equal(2)",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -1502,23 +1596,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{USER_AUTH_TOKEN}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "prerequest",
|
"listen": "prerequest",
|
||||||
"script": {
|
"script": {
|
||||||
"type": "text/javascript",
|
"type": "text/javascript",
|
||||||
"exec": [
|
"exec": [
|
||||||
""
|
"pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1 +0,0 @@
|
|||||||
This is a test filed used for uploading in the system test.
|
|
||||||
7
docker-compose-dev.yml
Normal file
7
docker-compose-dev.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
clamav:
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 3310:3310
|
||||||
|
image: clamav/clamav
|
||||||
@@ -5,12 +5,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
environment:
|
|
||||||
- APP_URL=${APP_URL}
|
|
||||||
- SHOW_HOME_PAGE=${SHOW_HOME_PAGE}
|
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION}
|
|
||||||
- ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES}
|
|
||||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
volumes:
|
volumes:
|
||||||
- "${PWD}/data:/opt/app/backend/data"
|
- "./data:/opt/app/backend/data"
|
||||||
|
# Optional: Add ClamAV (see README.md)
|
||||||
|
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||||
|
# clamav:
|
||||||
|
# restart: unless-stopped
|
||||||
|
# image: clamav/clamav
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
SHOW_HOME_PAGE=true
|
|
||||||
ALLOW_REGISTRATION=true
|
|
||||||
MAX_FILE_SIZE=1000000000
|
|
||||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
const nextConfig = {
|
const { version } = require('./package.json');
|
||||||
publicRuntimeConfig: {
|
|
||||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
|
|
||||||
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
|
|
||||||
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
|
|
||||||
ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const withPWA = require("next-pwa")({
|
const withPWA = require("next-pwa")({
|
||||||
dest: "public",
|
dest: "public",
|
||||||
disable: process.env.NODE_ENV == "development"
|
disable: process.env.NODE_ENV == "development",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
module.exports = withPWA({
|
||||||
module.exports = withPWA(nextConfig);
|
output: "standalone", env: {
|
||||||
|
VERSION: version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
2970
frontend/package-lock.json
generated
2970
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenv next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "dotenv next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"src/**/*.ts*\""
|
"format": "prettier --write \"src/**/*.ts*\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.4",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
"@mantine/core": "^5.5.2",
|
"@mantine/core": "^5.10.0",
|
||||||
"@mantine/dropzone": "^5.5.2",
|
"@mantine/dropzone": "^5.10.0",
|
||||||
"@mantine/form": "^5.5.2",
|
"@mantine/form": "^5.10.0",
|
||||||
"@mantine/hooks": "^5.5.2",
|
"@mantine/hooks": "^5.10.0",
|
||||||
"@mantine/modals": "^5.5.2",
|
"@mantine/modals": "^5.10.0",
|
||||||
"@mantine/next": "^5.5.2",
|
"@mantine/next": "^5.10.0",
|
||||||
"@mantine/notifications": "^5.5.2",
|
"@mantine/notifications": "^5.10.0",
|
||||||
"axios": "^0.26.1",
|
"axios": "^1.2.2",
|
||||||
"cookies-next": "^2.0.4",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.8.1",
|
"jose": "^4.11.2",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^12.3.1",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
"next-http-proxy-middleware": "^1.2.4",
|
"next-http-proxy-middleware": "^1.2.5",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"react": "18.0.0",
|
"p-limit": "^4.0.0",
|
||||||
"react-dom": "18.0.0",
|
"react": "^18.2.0",
|
||||||
"react-icons": "^4.4.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^4.7.1",
|
||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "17.0.23",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.4",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.0",
|
"@types/react-dom": "18.0.10",
|
||||||
"axios": "^0.26.1",
|
"axios": "^1.2.2",
|
||||||
"dotenv-cli": "^6.0.0",
|
"eslint": "8.31.0",
|
||||||
"eslint": "8.13.0",
|
"eslint-config-next": "^13.1.2",
|
||||||
"eslint-config-next": "12.1.5",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"prettier": "^2.8.2",
|
||||||
"prettier": "^2.7.1",
|
"tar": "^6.1.13",
|
||||||
"tar": "^6.1.11",
|
"typescript": "^4.9.4"
|
||||||
"typescript": "^4.6.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
frontend/src/components/account/ThemeSwitcher.tsx
Normal file
67
frontend/src/components/account/ThemeSwitcher.tsx
Normal 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;
|
||||||
128
frontend/src/components/account/showEnableTotpModal.tsx
Normal file
128
frontend/src/components/account/showEnableTotpModal.tsx
Normal 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;
|
||||||
20
frontend/src/components/account/showShareLinkModal.tsx
Normal file
20
frontend/src/components/account/showShareLinkModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
|
||||||
|
const showShareLinkModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
shareId: string,
|
||||||
|
appUrl: string
|
||||||
|
) => {
|
||||||
|
const link = `${appUrl}/share/${shareId}`;
|
||||||
|
return modals.openModal({
|
||||||
|
title: "Share link",
|
||||||
|
children: (
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput variant="filled" value={link} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showShareLinkModal;
|
||||||
86
frontend/src/components/admin/ManageUserTable.tsx
Normal file
86
frontend/src/components/admin/ManageUserTable.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||||
|
import User from "../../types/user.type";
|
||||||
|
import showUpdateUserModal from "./showUpdateUserModal";
|
||||||
|
|
||||||
|
const ManageUserTable = ({
|
||||||
|
users,
|
||||||
|
getUsers,
|
||||||
|
deleteUser,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
users: User[];
|
||||||
|
getUsers: () => void;
|
||||||
|
deleteUser: (user: User) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading
|
||||||
|
? skeletonRows
|
||||||
|
: users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>{user.isAdmin && <TbCheck />}</td>
|
||||||
|
<td>
|
||||||
|
<Group position="right">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
showUpdateUserModal(modals, user, getUsers)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TbEdit />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteUser(user)}
|
||||||
|
>
|
||||||
|
<TbTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skeletonRows = [...Array(10)].map((v, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default ManageUserTable;
|
||||||
@@ -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;
|
||||||
141
frontend/src/components/admin/configuration/AdminConfigTable.tsx
Normal file
141
frontend/src/components/admin/configuration/AdminConfigTable.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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(() => {
|
||||||
|
updatedConfigVariables = [];
|
||||||
|
toast.success("Configurations updated successfully");
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
} else {
|
||||||
|
configService
|
||||||
|
.updateMany(updatedConfigVariables)
|
||||||
|
.then(async () => {
|
||||||
|
await configService.finishSetup();
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminConfigTable;
|
||||||
@@ -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;
|
||||||
84
frontend/src/components/admin/showCreateUserModal.tsx
Normal file
84
frontend/src/components/admin/showCreateUserModal.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import userService from "../../services/user.service";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
|
const showCreateUserModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
getUsers: () => void
|
||||||
|
) => {
|
||||||
|
return modals.openModal({
|
||||||
|
title: <Title order={5}>Create user</Title>,
|
||||||
|
children: <Body modals={modals} getUsers={getUsers} />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Body = ({
|
||||||
|
modals,
|
||||||
|
getUsers,
|
||||||
|
}: {
|
||||||
|
modals: ModalsContextProps;
|
||||||
|
getUsers: () => void;
|
||||||
|
}) => {
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
isAdmin: false,
|
||||||
|
},
|
||||||
|
validate: yupResolver(
|
||||||
|
yup.object().shape({
|
||||||
|
email: yup.string().email(),
|
||||||
|
username: yup.string().min(3),
|
||||||
|
password: yup.string().min(8),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
userService
|
||||||
|
.create(values)
|
||||||
|
.then(() => {
|
||||||
|
getUsers();
|
||||||
|
modals.closeAll();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput label="Username" {...form.getInputProps("username")} />
|
||||||
|
<TextInput label="Email" {...form.getInputProps("email")} />
|
||||||
|
<PasswordInput
|
||||||
|
label="New password"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label="Admin privileges"
|
||||||
|
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<Group position="right">
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showCreateUserModal;
|
||||||
127
frontend/src/components/admin/showUpdateUserModal.tsx
Normal file
127
frontend/src/components/admin/showUpdateUserModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import userService from "../../services/user.service";
|
||||||
|
import User from "../../types/user.type";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
|
const showUpdateUserModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
user: User,
|
||||||
|
getUsers: () => void
|
||||||
|
) => {
|
||||||
|
return modals.openModal({
|
||||||
|
title: <Title order={5}>Update {user.username}</Title>,
|
||||||
|
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Body = ({
|
||||||
|
user,
|
||||||
|
modals,
|
||||||
|
getUsers,
|
||||||
|
}: {
|
||||||
|
modals: ModalsContextProps;
|
||||||
|
user: User;
|
||||||
|
getUsers: () => void;
|
||||||
|
}) => {
|
||||||
|
const accountForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
},
|
||||||
|
validate: yupResolver(
|
||||||
|
yup.object().shape({
|
||||||
|
email: yup.string().email(),
|
||||||
|
username: yup.string().min(3),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
validate: yupResolver(
|
||||||
|
yup.object().shape({
|
||||||
|
password: yup.string().min(8),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<form
|
||||||
|
id="accountForm"
|
||||||
|
onSubmit={accountForm.onSubmit(async (values) => {
|
||||||
|
userService
|
||||||
|
.update(user.id, values)
|
||||||
|
.then(() => {
|
||||||
|
getUsers();
|
||||||
|
modals.closeAll();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
{...accountForm.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
<TextInput label="Email" {...accountForm.getInputProps("email")} />
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label="Admin privileges"
|
||||||
|
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
<Accordion>
|
||||||
|
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
||||||
|
<Accordion.Control>Change password</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<form
|
||||||
|
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||||
|
userService
|
||||||
|
.update(user.id, {
|
||||||
|
password: values.password,
|
||||||
|
})
|
||||||
|
.then(() => toast.success("Password changed successfully"))
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<PasswordInput
|
||||||
|
label="New password"
|
||||||
|
{...passwordForm.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
<Button variant="light" type="submit">
|
||||||
|
Save new password
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
<Group position="right">
|
||||||
|
<Button type="submit" form="accountForm">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showUpdateUserModal;
|
||||||
139
frontend/src/components/auth/SignInForm.tsx
Normal file
139
frontend/src/components/auth/SignInForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
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";
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signIn = (email: string, password: string) => {
|
||||||
|
authService
|
||||||
|
.signIn(email, password)
|
||||||
|
.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
|
||||||
|
align="center"
|
||||||
|
sx={(theme) => ({
|
||||||
|
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||||
|
fontWeight: 900,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Welcome back
|
||||||
|
</Title>
|
||||||
|
{config.get("ALLOW_REGISTRATION") && (
|
||||||
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
|
You don't have an account yet?{" "}
|
||||||
|
<Anchor component={Link} href={"signUp"} size="sm">
|
||||||
|
{"Sign up"}
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => {
|
||||||
|
if (showTotp)
|
||||||
|
signInTotp(values.emailOrUsername, values.password, values.totp);
|
||||||
|
else signIn(values.emailOrUsername, values.password);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
label="Email or username"
|
||||||
|
placeholder="you@email.com"
|
||||||
|
{...form.getInputProps("emailOrUsername")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Your password"
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignInForm;
|
||||||
@@ -9,39 +9,35 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { NextLink } from "@mantine/next";
|
import Link from "next/link";
|
||||||
import getConfig from "next/config";
|
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
const SignUpForm = () => {
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email().required(),
|
||||||
|
username: yup.string().min(3).required(),
|
||||||
password: yup.string().min(8).required(),
|
password: yup.string().min(8).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
},
|
},
|
||||||
validate: yupResolver(validationSchema),
|
validate: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signIn = (email: string, password: string) => {
|
const signUp = (email: string, username: string, password: string) => {
|
||||||
authService
|
authService
|
||||||
.signIn(email, password)
|
.signUp(email, username, password)
|
||||||
.then(() => window.location.replace("/upload"))
|
.then(() => window.location.replace("/"))
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
.catch(toast.axiosError);
|
||||||
};
|
|
||||||
const signUp = (email: string, password: string) => {
|
|
||||||
authService
|
|
||||||
.signUp(email, password)
|
|
||||||
.then(() => signIn(email, password))
|
|
||||||
.catch((e) => toast.error(e.response.data.message));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,33 +49,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{mode == "signUp" ? "Sign up" : "Welcome back"}
|
Sign up
|
||||||
</Title>
|
</Title>
|
||||||
{publicRuntimeConfig.ALLOW_REGISTRATION == "true" && (
|
{config.get("ALLOW_REGISTRATION") && (
|
||||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
{mode == "signUp"
|
You have an account already?{" "}
|
||||||
? "You have an account already?"
|
<Anchor component={Link} href={"signIn"} size="sm">
|
||||||
: "You don't have an account yet?"}{" "}
|
Sign in
|
||||||
<Anchor
|
|
||||||
component={NextLink}
|
|
||||||
href={mode == "signUp" ? "signIn" : "signUp"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{mode == "signUp" ? "Sign in" : "Sign up"}
|
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) =>
|
onSubmit={form.onSubmit((values) =>
|
||||||
mode == "signIn"
|
signUp(values.email, values.username, values.password)
|
||||||
? signIn(values.email, values.password)
|
|
||||||
: signUp(values.email, values.password)
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
placeholder="john.doe"
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="you@email.com"
|
placeholder="you@email.com"
|
||||||
|
mt="md"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -89,7 +83,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button fullWidth mt="xl" type="submit">
|
<Button fullWidth mt="xl" type="submit">
|
||||||
{mode == "signUp" ? "Let's get started" : "Sign in"}
|
Let's get started
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -97,4 +91,4 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthForm;
|
export default SignUpForm;
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
||||||
import { NextLink } from "@mantine/next";
|
import Link from "next/link";
|
||||||
import { TbDoorExit, TbLink } from "react-icons/tb";
|
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
|
||||||
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
|
|
||||||
const ActionAvatar = () => {
|
const ActionAvatar = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-start" withinPortal>
|
<Menu position="bottom-start" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -13,15 +16,28 @@ const ActionAvatar = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={NextLink}
|
component={Link}
|
||||||
href="/account/shares"
|
href="/account/shares"
|
||||||
icon={<TbLink size={14} />}
|
icon={<TbLink size={14} />}
|
||||||
>
|
>
|
||||||
My shares
|
My shares
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||||
|
My account
|
||||||
|
</Menu.Item>
|
||||||
|
{user!.isAdmin && (
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
href="/admin"
|
||||||
|
icon={<TbSettings size={14} />}
|
||||||
|
>
|
||||||
|
Administration
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
authService.signOut();
|
await authService.signOut();
|
||||||
}}
|
}}
|
||||||
icon={<TbDoorExit size={14} />}
|
icon={<TbDoorExit size={14} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,18 +11,16 @@ import {
|
|||||||
Transition,
|
Transition,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { NextLink } from "@mantine/next";
|
import Link from "next/link";
|
||||||
import getConfig from "next/config";
|
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
import ActionAvatar from "./ActionAvatar";
|
import ActionAvatar from "./ActionAvatar";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 60;
|
const HEADER_HEIGHT = 60;
|
||||||
|
|
||||||
type Link = {
|
type NavLink = {
|
||||||
link?: string;
|
link?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
component?: ReactNode;
|
component?: ReactNode;
|
||||||
@@ -109,7 +107,9 @@ const useStyles = createStyles((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const NavBar = () => {
|
const NavBar = () => {
|
||||||
const user = useUser();
|
const { user } = useUser();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const [opened, toggleOpened] = useDisclosure(false);
|
const [opened, toggleOpened] = useDisclosure(false);
|
||||||
|
|
||||||
const authenticatedLinks = [
|
const authenticatedLinks = [
|
||||||
@@ -122,7 +122,7 @@ const NavBar = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<Link[]>([
|
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
||||||
{
|
{
|
||||||
link: "/auth/signIn",
|
link: "/auth/signIn",
|
||||||
label: "Sign in",
|
label: "Sign in",
|
||||||
@@ -130,7 +130,7 @@ const NavBar = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (publicRuntimeConfig.SHOW_HOME_PAGE == "true")
|
if (config.get("SHOW_HOME_PAGE"))
|
||||||
setUnauthenticatedLinks((array) => [
|
setUnauthenticatedLinks((array) => [
|
||||||
{
|
{
|
||||||
link: "/",
|
link: "/",
|
||||||
@@ -139,7 +139,7 @@ const NavBar = () => {
|
|||||||
...array,
|
...array,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (publicRuntimeConfig.ALLOW_REGISTRATION == "true")
|
if (config.get("ALLOW_REGISTRATION"))
|
||||||
setUnauthenticatedLinks((array) => [
|
setUnauthenticatedLinks((array) => [
|
||||||
...array,
|
...array,
|
||||||
{
|
{
|
||||||
@@ -161,7 +161,7 @@ const NavBar = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<NextLink
|
<Link
|
||||||
key={link.label}
|
key={link.label}
|
||||||
href={link.link ?? ""}
|
href={link.link ?? ""}
|
||||||
onClick={() => toggleOpened.toggle()}
|
onClick={() => toggleOpened.toggle()}
|
||||||
@@ -170,7 +170,7 @@ const NavBar = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</NextLink>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -178,12 +178,12 @@ const NavBar = () => {
|
|||||||
return (
|
return (
|
||||||
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||||
<Container className={classes.header}>
|
<Container className={classes.header}>
|
||||||
<NextLink href="/">
|
<Link href="/" passHref>
|
||||||
<Group>
|
<Group>
|
||||||
<Logo height={35} width={35} />
|
<Logo height={35} width={35} />
|
||||||
<Text weight={600}>Pingvin Share</Text>
|
<Text weight={600}>Pingvin Share</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</NextLink>
|
</Link>
|
||||||
<Group spacing={5} className={classes.links}>
|
<Group spacing={5} className={classes.links}>
|
||||||
<Group>{items} </Group>
|
<Group>{items} </Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -9,52 +9,10 @@ const FileList = ({
|
|||||||
shareId,
|
shareId,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
files: any[];
|
files?: any[];
|
||||||
shareId: string;
|
shareId: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const skeletonRows = [...Array(5)].map((c, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={30} width={30} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={14} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={14} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={25} width={25} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
const rows = files.map((file) => (
|
|
||||||
<tr key={file.name}>
|
|
||||||
<td>{file.name}</td>
|
|
||||||
<td>{byteStringToHumanSizeString(file.size)}</td>
|
|
||||||
<td>
|
|
||||||
{file.uploadingState ? (
|
|
||||||
file.uploadingState != "finished" ? (
|
|
||||||
<Loader size={22} />
|
|
||||||
) : (
|
|
||||||
<TbCircleCheck color="green" size={22} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<ActionIcon
|
|
||||||
size={25}
|
|
||||||
onClick={async () => {
|
|
||||||
await shareService.downloadFile(shareId, file.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TbDownload />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -64,9 +22,53 @@ const FileList = ({
|
|||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{isLoading ? skeletonRows : rows}</tbody>
|
<tbody>
|
||||||
|
{isLoading
|
||||||
|
? skeletonRows
|
||||||
|
: files!.map((file) => (
|
||||||
|
<tr key={file.name}>
|
||||||
|
<td>{file.name}</td>
|
||||||
|
<td>{byteStringToHumanSizeString(file.size)}</td>
|
||||||
|
<td>
|
||||||
|
{file.uploadingState ? (
|
||||||
|
file.uploadingState != "finished" ? (
|
||||||
|
<Loader size={22} />
|
||||||
|
) : (
|
||||||
|
<TbCircleCheck color="green" size={22} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ActionIcon
|
||||||
|
size={25}
|
||||||
|
onClick={async () => {
|
||||||
|
await shareService.downloadFile(shareId, file.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TbDownload />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skeletonRows = [...Array(5)].map((c, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<Skeleton height={30} width={30} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton height={14} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton height={14} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton height={25} width={25} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
|
||||||
export default FileList;
|
export default FileList;
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
||||||
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
||||||
import getConfig from "next/config";
|
|
||||||
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
||||||
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import { FileUpload } from "../../types/File.type";
|
import { FileUpload } from "../../types/File.type";
|
||||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -35,31 +33,43 @@ const useStyles = createStyles((theme) => ({
|
|||||||
|
|
||||||
const Dropzone = ({
|
const Dropzone = ({
|
||||||
isUploading,
|
isUploading,
|
||||||
|
files,
|
||||||
setFiles,
|
setFiles,
|
||||||
}: {
|
}: {
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
|
files: FileUpload[];
|
||||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||||
}) => {
|
}) => {
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
const openRef = useRef<() => void>();
|
const openRef = useRef<() => void>();
|
||||||
return (
|
return (
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<MantineDropzone
|
<MantineDropzone
|
||||||
maxSize={parseInt(publicRuntimeConfig.MAX_FILE_SIZE!)}
|
|
||||||
onReject={(e) => {
|
onReject={(e) => {
|
||||||
toast.error(e[0].errors[0].message);
|
toast.error(e[0].errors[0].message);
|
||||||
}}
|
}}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
openRef={openRef as ForwardedRef<() => void>}
|
openRef={openRef as ForwardedRef<() => void>}
|
||||||
onDrop={(files) => {
|
onDrop={(newFiles: FileUpload[]) => {
|
||||||
if (files.length > 100) {
|
const fileSizeSum = [...newFiles, ...files].reduce(
|
||||||
toast.error("You can't upload more than 100 files per share.");
|
(n, { size }) => n + size,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fileSizeSum > config.get("MAX_SHARE_SIZE")) {
|
||||||
|
toast.error(
|
||||||
|
`Your files exceed the maximum share size of ${byteStringToHumanSizeString(
|
||||||
|
config.get("MAX_SHARE_SIZE")
|
||||||
|
)}.`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const newFiles = files.map((file) => {
|
newFiles = newFiles.map((newFile) => {
|
||||||
(file as FileUpload).uploadingProgress = 0;
|
newFile.uploadingProgress = 0;
|
||||||
return file as FileUpload;
|
return newFile;
|
||||||
});
|
});
|
||||||
setFiles(newFiles);
|
setFiles([...newFiles, ...files]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classes.dropzone}
|
className={classes.dropzone}
|
||||||
@@ -75,8 +85,8 @@ const Dropzone = ({
|
|||||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||||
Drag'n'drop files here to start your share. We can accept
|
Drag'n'drop files here to start your share. We can accept
|
||||||
only files that are less than{" "}
|
only files that are less than{" "}
|
||||||
{byteStringToHumanSizeString(publicRuntimeConfig.MAX_FILE_SIZE)} in
|
{byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in
|
||||||
size.
|
total.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</MantineDropzone>
|
</MantineDropzone>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RingProgress } from "@mantine/core";
|
import { Loader, RingProgress } from "@mantine/core";
|
||||||
import { TbCircleCheck, TbCircleX } from "react-icons/tb";
|
import { TbCircleCheck } from "react-icons/tb";
|
||||||
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
||||||
if (progress > 0 && progress < 100) {
|
if (progress > 0 && progress < 100) {
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +12,7 @@ const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
|||||||
} else if (progress >= 100) {
|
} else if (progress >= 100) {
|
||||||
return <TbCircleCheck color="green" size={22} />;
|
return <TbCircleCheck color="green" size={22} />;
|
||||||
} else {
|
} else {
|
||||||
return <TbCircleX color="red" size={22} />;
|
return <Loader color="red" size={19} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ import { useClipboard } from "@mantine/hooks";
|
|||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import getConfig from "next/config";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { TbCopy } from "react-icons/tb";
|
import { TbCopy } from "react-icons/tb";
|
||||||
import { Share } from "../../../types/share.type";
|
import { Share } from "../../../types/share.type";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
const showCompletedUploadModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
share: Share,
|
||||||
|
appUrl: string
|
||||||
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
@@ -28,29 +29,33 @@ const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
|||||||
<Title order={4}>Share ready</Title>
|
<Title order={4}>Share ready</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
children: <Body share={share} />,
|
children: <Body share={share} appUrl={appUrl} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ share }: { share: Share }) => {
|
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const link = `${window.location.origin}/share/${share.id}`;
|
|
||||||
|
const link = `${appUrl}/share/${share.id}`;
|
||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
readOnly
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={link}
|
value={link}
|
||||||
rightSection={
|
rightSection={
|
||||||
<ActionIcon
|
window.isSecureContext && (
|
||||||
onClick={() => {
|
<ActionIcon
|
||||||
clipboard.copy(link);
|
onClick={() => {
|
||||||
toast.success("Your link was copied to the keyboard.");
|
clipboard.copy(link);
|
||||||
}}
|
toast.success("Your link was copied to the keyboard.");
|
||||||
>
|
}}
|
||||||
<TbCopy />
|
>
|
||||||
</ActionIcon>
|
<TbCopy />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -6,41 +6,41 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
|
MultiSelect,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import getConfig from "next/config";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TbAlertCircle } from "react-icons/tb";
|
import { TbAlertCircle } from "react-icons/tb";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import shareService from "../../../services/share.service";
|
import shareService from "../../../services/share.service";
|
||||||
import { ShareSecurity } from "../../../types/share.type";
|
import { CreateShare } from "../../../types/share.type";
|
||||||
import ExpirationPreview from "../ExpirationPreview";
|
import ExpirationPreview from "../ExpirationPreview";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
|
||||||
|
|
||||||
const showCreateUploadModal = (
|
const showCreateUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
isSignedIn: boolean,
|
options: {
|
||||||
uploadCallback: (
|
isUserSignedIn: boolean;
|
||||||
id: string,
|
appUrl: string;
|
||||||
expiration: string,
|
allowUnauthenticatedShares: boolean;
|
||||||
security: ShareSecurity
|
enableEmailRecepients: boolean;
|
||||||
) => void
|
},
|
||||||
|
uploadCallback: (createShare: CreateShare) => void
|
||||||
) => {
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={4}>Share</Title>,
|
title: <Title order={4}>Share</Title>,
|
||||||
children: (
|
children: (
|
||||||
<CreateUploadModalBody
|
<CreateUploadModalBody
|
||||||
isSignedIn={isSignedIn}
|
options={options}
|
||||||
uploadCallback={uploadCallback}
|
uploadCallback={uploadCallback}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -49,20 +49,19 @@ const showCreateUploadModal = (
|
|||||||
|
|
||||||
const CreateUploadModalBody = ({
|
const CreateUploadModalBody = ({
|
||||||
uploadCallback,
|
uploadCallback,
|
||||||
isSignedIn,
|
options,
|
||||||
}: {
|
}: {
|
||||||
uploadCallback: (
|
uploadCallback: (createShare: CreateShare) => void;
|
||||||
id: string,
|
options: {
|
||||||
expiration: string,
|
isUserSignedIn: boolean;
|
||||||
security: ShareSecurity
|
appUrl: string;
|
||||||
) => void;
|
allowUnauthenticatedShares: boolean;
|
||||||
isSignedIn: boolean;
|
enableEmailRecepients: boolean;
|
||||||
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
|
||||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
|
|
||||||
);
|
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
link: yup
|
link: yup
|
||||||
@@ -79,9 +78,10 @@ const CreateUploadModalBody = ({
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
link: "",
|
link: "",
|
||||||
|
recipients: [] as string[],
|
||||||
password: undefined,
|
password: undefined,
|
||||||
maxViews: undefined,
|
maxViews: undefined,
|
||||||
|
description: undefined,
|
||||||
expiration_num: 1,
|
expiration_num: 1,
|
||||||
expiration_unit: "-days",
|
expiration_unit: "-days",
|
||||||
never_expires: false,
|
never_expires: false,
|
||||||
@@ -90,7 +90,7 @@ const CreateUploadModalBody = ({
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
{showNotSignedInAlert && !isSignedIn && (
|
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||||
<Alert
|
<Alert
|
||||||
withCloseButton
|
withCloseButton
|
||||||
onClose={() => setShowNotSignedInAlert(false)}
|
onClose={() => setShowNotSignedInAlert(false)}
|
||||||
@@ -110,9 +110,15 @@ const CreateUploadModalBody = ({
|
|||||||
const expiration = form.values.never_expires
|
const expiration = form.values.never_expires
|
||||||
? "never"
|
? "never"
|
||||||
: form.values.expiration_num + form.values.expiration_unit;
|
: form.values.expiration_num + form.values.expiration_unit;
|
||||||
uploadCallback(values.link, expiration, {
|
uploadCallback({
|
||||||
password: values.password,
|
id: values.link,
|
||||||
maxViews: values.maxViews,
|
expiration: expiration,
|
||||||
|
recipients: values.recipients,
|
||||||
|
description: values.description,
|
||||||
|
security: {
|
||||||
|
password: values.password,
|
||||||
|
maxViews: values.maxViews,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ const CreateUploadModalBody = ({
|
|||||||
color: theme.colors.gray[6],
|
color: theme.colors.gray[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{window.location.origin}/share/
|
{options.appUrl}/share/
|
||||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||||
</Text>
|
</Text>
|
||||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||||
@@ -211,7 +217,6 @@ const CreateUploadModalBody = ({
|
|||||||
label="Never Expires"
|
label="Never Expires"
|
||||||
{...form.getInputProps("never_expires")}
|
{...form.getInputProps("never_expires")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Preview expiration date text */}
|
{/* Preview expiration date text */}
|
||||||
<Text
|
<Text
|
||||||
italic
|
italic
|
||||||
@@ -222,8 +227,50 @@ const CreateUploadModalBody = ({
|
|||||||
>
|
>
|
||||||
{ExpirationPreview({ form })}
|
{ExpirationPreview({ form })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Accordion>
|
<Accordion>
|
||||||
|
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
||||||
|
<Accordion.Control>Description</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack align="stretch">
|
||||||
|
<Textarea
|
||||||
|
variant="filled"
|
||||||
|
placeholder="Note for the recepients"
|
||||||
|
{...form.getInputProps("description")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
{options.enableEmailRecepients && (
|
||||||
|
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
|
||||||
|
<Accordion.Control>Email recipients</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<MultiSelect
|
||||||
|
data={form.values.recipients}
|
||||||
|
placeholder="Enter email recipients"
|
||||||
|
searchable
|
||||||
|
{...form.getInputProps("recipients")}
|
||||||
|
creatable
|
||||||
|
getCreateLabel={(query) => `+ ${query}`}
|
||||||
|
onCreate={(query) => {
|
||||||
|
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||||
|
form.setFieldError(
|
||||||
|
"recipients",
|
||||||
|
"Invalid email address"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setFieldError("recipients", null);
|
||||||
|
form.setFieldValue("recipients", [
|
||||||
|
...form.values.recipients,
|
||||||
|
query,
|
||||||
|
]);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>Security options</Accordion.Control>
|
<Accordion.Control>Security options</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
|
|||||||
14
frontend/src/hooks/config.hook.ts
Normal file
14
frontend/src/hooks/config.hook.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import configService from "../services/config.service";
|
||||||
|
import Config from "../types/config.type";
|
||||||
|
|
||||||
|
export const ConfigContext = createContext<Config[] | null>(null);
|
||||||
|
|
||||||
|
const useConfig = () => {
|
||||||
|
const configVariables = useContext(ConfigContext) as Config[];
|
||||||
|
return {
|
||||||
|
get: (key: string) => configService.get(key, configVariables),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useConfig;
|
||||||
30
frontend/src/hooks/usePreferences.ts
Normal file
30
frontend/src/hooks/usePreferences.ts
Normal 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;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { CurrentUser } from "../types/user.type";
|
import { UserHook } from "../types/user.type";
|
||||||
|
|
||||||
export const UserContext = createContext<CurrentUser | null>(null);
|
export const UserContext = createContext<UserHook>({
|
||||||
|
user: null,
|
||||||
|
setUser: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
const useUser = () => {
|
const useUser = () => {
|
||||||
return useContext(UserContext);
|
return useContext(UserContext);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user