Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
880ad85a1e | ||
|
|
e40cc0f48b | ||
|
|
46d667776f | ||
|
|
99de4e57e1 | ||
|
|
00d0df731b | ||
|
|
599d8caa31 | ||
|
|
aff58da3a2 | ||
|
|
63e0af3484 | ||
|
|
c2ddce6203 | ||
|
|
c6e1f07f51 | ||
|
|
c8021a42b7 | ||
|
|
d0901d497b | ||
|
|
ffdecbd32e | ||
|
|
712cfe625a | ||
|
|
c2b87aba5c | ||
|
|
e4019612f8 | ||
|
|
af6e2b61c0 | ||
|
|
7237928844 | ||
|
|
38986c971a | ||
|
|
84d29dff68 | ||
|
|
41c3bafbd7 | ||
|
|
c52a4d5e3a | ||
|
|
239b18cdae | ||
|
|
a8a56321dd |
10
.env.example
10
.env.example
@@ -1,10 +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
|
||||
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
|
||||
working-directory: ./backend
|
||||
run: npm install
|
||||
- name: Create .env file
|
||||
working-directory: ./backend
|
||||
run: mv .env.example .env
|
||||
- name: Run Server and Test with Newman
|
||||
working-directory: ./backend
|
||||
run: npm run test:system
|
||||
|
||||
22
.github/workflows/close_inactive_issues.yml
vendored
Normal file
22
.github/workflows/close_inactive_issues.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "00 00 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
166
CHANGELOG.md
166
CHANGELOG.md
@@ -1,4 +1,152 @@
|
||||
## 0.0.1 (2022-10-17)
|
||||
### [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `ALLOW_UNAUTHENTICATED_SHARES` to docker compose file ([599d8ca](https://github.com/stonith404/pingvin-share/commit/599d8caa31dc018c14c959d6602ac652eaef5da2))
|
||||
* only log jobs if they actually did something ([e40cc0f](https://github.com/stonith404/pingvin-share/commit/e40cc0f48beec09e18738de1b445cabd9daab09b))
|
||||
* share finishes before all files are uploaded ([99de4e5](https://github.com/stonith404/pingvin-share/commit/99de4e57e18df54596e168a57b94c55d7a834472))
|
||||
|
||||
## [0.1.0](https://github.com/stonith404/pingvin-share/compare/v0.0.1...v0.1.0) (2022-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add rate limiting ([712cfe6](https://github.com/stonith404/pingvin-share/commit/712cfe625a19dc9790cda5fbc2843fed0836b860))
|
||||
* allow unauthenticated uploads ([84d29df](https://github.com/stonith404/pingvin-share/commit/84d29dff68d0ea9d76d9a35f9fb7dff95d3dda1b))
|
||||
* **frontend:** remove footer ([c52a4d5](https://github.com/stonith404/pingvin-share/commit/c52a4d5e3ad717a10d15b7fe1dbf359b041c0976))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* infinite loading when file size is small ([c2ddce6](https://github.com/stonith404/pingvin-share/commit/c2ddce62038e561d292f23fc6089562e64f1ffe9))
|
||||
* only show not signed in warning if not signed in ([c6e1f07](https://github.com/stonith404/pingvin-share/commit/c6e1f07f51e9cdd914bb70fb19dd81b90a470563))
|
||||
* opt out of static site generation to enable `publicRuntimeConfig` ([239b18c](https://github.com/stonith404/pingvin-share/commit/239b18cdae6367322bcdacb6b2bbaa1028295cc4))
|
||||
* visitor count doesn't get incremented ([c8021a4](https://github.com/stonith404/pingvin-share/commit/c8021a42b7fb094e587325bf855fc3133b6b96b0))
|
||||
|
||||
### [0.0.1](https://github.com/stonith404/pingvin-share/compare/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214...v0.0.1) (2022-10-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
|
||||
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
|
||||
* Added "never" expiration date ([56349c6](https://github.com/stonith404/pingvin-share/commit/56349c6f4cc739d07bcf8ad862b0868e09342883))
|
||||
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
|
||||
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
|
||||
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
|
||||
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
|
||||
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
|
||||
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
|
||||
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
|
||||
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
@@ -28,19 +176,3 @@
|
||||
* upload volume path ([7522221](https://github.com/stonith404/pingvin-share/commit/7522221ee163cb0bd6144e7b924c77065f223fb9))
|
||||
* wrong environment configuration for `ALLOW_REGISTRATION` ([759db40](https://github.com/stonith404/pingvin-share/commit/759db40ac9f42ff71a795ceec521a7f9531d71c9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
|
||||
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
|
||||
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
|
||||
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
|
||||
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
|
||||
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
|
||||
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
|
||||
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
|
||||
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
|
||||
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Getting started
|
||||
|
||||
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.
|
||||
|
||||
Branch naming convention is as following
|
||||
@@ -74,20 +75,21 @@ The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
||||
#### Setup
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. 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. Push the database schema to the database by running `npx prisma db push`
|
||||
2. Install the dependencies with `npm install`
|
||||
3. 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`
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Start the backend first
|
||||
2. Open the `frontend` folder
|
||||
3. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
|
||||
4. Install the dependencies with `npm install`
|
||||
5. Start the frontend with `npm run dev`
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
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
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: on frontend change
|
||||
FROM node:18-slim AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
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
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./backend .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:18 AS runner
|
||||
WORKDIR /opt/app/frontend
|
||||
# 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=production
|
||||
COPY --from=frontend-builder /opt/app/next.config.js .
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /opt/app/public ./public
|
||||
COPY --from=frontend-builder /opt/app/.next ./.next
|
||||
COPY --from=frontend-builder /opt/app/node_modules ./node_modules
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# 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
|
||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||
COPY --from=backend-builder /opt/app/dist ./dist
|
||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN npm i -g dotenv-cli
|
||||
|
||||
EXPOSE 3000
|
||||
CMD cd frontend && dotenv -e .env.development node_modules/.bin/next start & cd backend && npm run prod
|
||||
CMD node frontend/server.js & cd backend && npm run prod
|
||||
36
README.md
36
README.md
@@ -2,45 +2,41 @@
|
||||
|
||||
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
|
||||
|
||||
- Spin up your instance within 2 minutes
|
||||
- Create a share with files that you can access with a link
|
||||
- No file size limit, only your disk will be your limit
|
||||
- Set a share expiration
|
||||
- Optionally secure your share with a visitor limit and a password
|
||||
- Email recepients
|
||||
- Light & dark mode
|
||||
|
||||
## 🐧 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
|
||||
|
||||
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
||||
|
||||
1. Download the `docker-compose.yml` and `.env.example` file.
|
||||
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`
|
||||
### Recommended installation
|
||||
|
||||
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 🐧!
|
||||
|
||||
### Environment variables
|
||||
### Additional resources
|
||||
|
||||
| Variable | Description | Possible values |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------- | --------------- |
|
||||
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
|
||||
| `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 |
|
||||
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
|
||||
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
|
||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
Just update the docker container by running `docker compose pull && docker compose up -d`
|
||||
|
||||
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
|
||||
Run `docker compose pull && docker compose up -d` to update your docker container
|
||||
|
||||
## 🖤 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,7 +0,0 @@
|
||||
# CONFIGURATION
|
||||
APP_URL=http://localhost:3000
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=5000000000
|
||||
|
||||
# SECURITY
|
||||
JWT_SECRET=random-string
|
||||
3164
backend/package-lock.json
generated
3164
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,63 +3,69 @@
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "dotenv -- nest start --watch",
|
||||
"prod": "npx prisma migrate deploy && dotenv node dist/main",
|
||||
"dev": "nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"lint": "eslint '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/system/newman-system-tests.json"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.1.2",
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.1.2",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^9.0.0",
|
||||
"@nestjs/mapped-types": "^1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.1.2",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/swagger": "^6.1.2",
|
||||
"@nestjs/throttler": "^3.1.0",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.29.1",
|
||||
"argon2": "^0.30.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"content-disposition": "^0.5.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.8.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.5.7"
|
||||
"rxjs": "^7.6.0",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.4",
|
||||
"@nestjs/cli": "^9.1.5",
|
||||
"@nestjs/schematics": "^9.0.3",
|
||||
"@nestjs/testing": "^9.1.2",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@nestjs/testing": "^9.2.1",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.7.23",
|
||||
"@types/node": "^18.11.10",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
||||
"@typescript-eslint/parser": "^5.40.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.7.1",
|
||||
"prisma": "^4.4.0",
|
||||
"prettier": "^2.8.0",
|
||||
"prisma": "^4.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.8.4"
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.3",
|
||||
"wait-on": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 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 SET NULL 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,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;
|
||||
@@ -12,10 +12,10 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
email String @unique
|
||||
password String
|
||||
firstName String?
|
||||
lastName String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
@@ -39,11 +39,21 @@ model Share {
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
expiration DateTime
|
||||
description String?
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id])
|
||||
security ShareSecurity?
|
||||
files File[]
|
||||
creatorId String?
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
security ShareSecurity?
|
||||
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 {
|
||||
@@ -67,3 +77,15 @@ model ShareSecurity {
|
||||
shareId String? @unique
|
||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
key String @id
|
||||
type String
|
||||
value String
|
||||
description String
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
}
|
||||
|
||||
151
backend/prisma/seed/config.seed.ts
Normal file
151
backend/prisma/seed/config.seed.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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",
|
||||
secret: false,
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "APP_URL",
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "SHOW_HOME_PAGE",
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_REGISTRATION",
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "MAX_FILE_SIZE",
|
||||
description: "Maximum file size in bytes",
|
||||
type: "number",
|
||||
value: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "JWT_SECRET",
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
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",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_USERNAME",
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
obscured: true,
|
||||
},
|
||||
];
|
||||
|
||||
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,27 +1,53 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { HttpException, HttpStatus, Module } from "@nestjs/common";
|
||||
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { JobsService } from "./jobs/jobs.service";
|
||||
|
||||
import { FileController } from "./file/file.controller";
|
||||
import { MulterModule } from "@nestjs/platform-express";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { FileModule } from "./file/file.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 { UserController } from "./user/user.controller";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
MulterModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
fileFilter: (req: Request, file, cb) => {
|
||||
const MAX_FILE_SIZE = config.get("MAX_FILE_SIZE");
|
||||
const requestFileSize = parseInt(req.headers["content-length"]);
|
||||
const isValidFileSize = requestFileSize <= MAX_FILE_SIZE;
|
||||
cb(
|
||||
!isValidFileSize &&
|
||||
new HttpException(
|
||||
`File must be smaller than ${MAX_FILE_SIZE} bytes`,
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
),
|
||||
isValidFileSize
|
||||
);
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [PrismaService, JobsService],
|
||||
controllers: [UserController, ShareController, FileController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -3,14 +3,20 @@ import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { GetUser } from "./decorator/getUser.decorator";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { JwtGuard } from "./guard/jwt.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
@@ -19,19 +25,27 @@ export class AuthController {
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (this.config.get("ALLOW_REGISTRATION") == "false")
|
||||
async signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
return this.authService.signUp(dto);
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn")
|
||||
@HttpCode(200)
|
||||
signIn(@Body() dto: AuthSignInDTO) {
|
||||
return this.authService.signIn(dto);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
|
||||
await this.authService.updatePassword(user, dto.oldPassword, dto.password);
|
||||
}
|
||||
|
||||
@Post("token")
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
@@ -27,7 +28,9 @@ export class AuthService {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: !this.config.get("SETUP_FINISHED"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,16 +41,22 @@ export class AuthService {
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
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) {
|
||||
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: {
|
||||
email: dto.email,
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,6 +69,18 @@ export class AuthService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (argon.verify(user.password, oldPassword))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const hash = await argon.hash(newPassword);
|
||||
|
||||
this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hash },
|
||||
});
|
||||
}
|
||||
|
||||
async createAccessToken(user: User) {
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
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";
|
||||
|
||||
export class AuthSignInDTO extends PickType(UserDTO, [
|
||||
"email",
|
||||
"password",
|
||||
] as const) {}
|
||||
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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,7 +1,17 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard("jwt") {
|
||||
constructor() {
|
||||
constructor(private config: ConfigService) {
|
||||
super();
|
||||
}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch {
|
||||
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
config.get("JWT_SECRET");
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get("JWT_SECRET"),
|
||||
@@ -18,7 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
const user: User = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
47
backend/src/config/config.controller.ts
Normal file
47
backend/src/config/config.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
||||
import { ConfigDTO } from "./dto/config.dto";
|
||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||
|
||||
@Controller("configs")
|
||||
export class ConfigController {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
@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/:key")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
|
||||
return new AdminConfigDTO().from(
|
||||
await this.configService.update(key, data.value)
|
||||
);
|
||||
}
|
||||
|
||||
@Post("admin/finishSetup")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async finishSetup() {
|
||||
return await this.configService.finishSetup();
|
||||
}
|
||||
}
|
||||
21
backend/src/config/config.module.ts
Normal file
21
backend/src/config/config.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
import { ConfigService } from "./config.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: "CONFIG_VARIABLES",
|
||||
useFactory: async (prisma: PrismaService) => {
|
||||
return await prisma.config.findMany();
|
||||
},
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
exports: [ConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
70
backend/src/config/config.service.ts
Normal file
70
backend/src/config/config.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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") 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 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)
|
||||
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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
28
backend/src/config/dto/adminConfig.dto.ts
Normal file
28
backend/src/config/dto/adminConfig.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
|
||||
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 })
|
||||
);
|
||||
}
|
||||
}
|
||||
9
backend/src/config/dto/updateConfig.dto.ts
Normal file
9
backend/src/config/dto/updateConfig.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, ValidateIf } from "class-validator";
|
||||
|
||||
class UpdateConfigDTO {
|
||||
@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 {}
|
||||
34
backend/src/email/email.service.ts
Normal file
34
backend/src/email/email.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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) {}
|
||||
|
||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||
// create reusable transporter object using the default SMTP transport
|
||||
const transporter = 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"),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
|
||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Files shared with you",
|
||||
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}\nShared securely with Pingvin Share 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseFilePipeBuilder,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
@@ -32,13 +31,7 @@ export class FileController {
|
||||
})
|
||||
)
|
||||
async create(
|
||||
@UploadedFile(
|
||||
new ParseFilePipeBuilder()
|
||||
.addMaxSizeValidator({
|
||||
maxSize: parseInt(process.env.MAX_FILE_SIZE),
|
||||
})
|
||||
.build()
|
||||
)
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
@@ -86,7 +79,7 @@ export class FileController {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}"`,
|
||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
import { ShareModule } from "src/share/share.module";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
providers: [FileService, FileValidationPipe],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -80,6 +80,7 @@ export class FileService {
|
||||
|
||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
||||
|
||||
return `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
||||
|
||||
17
backend/src/file/pipe/fileValidation.pipe.ts
Normal file
17
backend/src/file/pipe/fileValidation.pipe.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileValidationPipe implements PipeTransform {
|
||||
constructor(private config: ConfigService) {}
|
||||
async transform(value: any, metadata: ArgumentMetadata) {
|
||||
if (value.size > this.config.get("MAX_FILE_SIZE"))
|
||||
throw new BadRequestException("File is ");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
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,8 +1,9 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as moment from "moment";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
@@ -31,14 +32,35 @@ export class JobsService {
|
||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
if (expiredShares.length > 0)
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
const files = fs.readdirSync("./data/uploads/_temp");
|
||||
|
||||
for (const file of files) {
|
||||
const stats = fs.statSync(`./data/uploads/_temp/${file}`);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
.isBefore(moment());
|
||||
|
||||
if (isOlderThanOneDay) fs.rmSync(`./data/uploads/_temp/${file}`);
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${files.length} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredRefreshTokens() {
|
||||
const expiredShares = await this.prisma.refreshToken.deleteMany({
|
||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
console.log(`job: deleted ${expiredShares.count} expired refresh tokens`);
|
||||
|
||||
if (expiredRefreshTokens.count > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
constructor(config: ConfigService) {
|
||||
constructor() {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
url: "file:../data/pingvin-share.db",
|
||||
url: "file:../data/pingvin-share.db?connection_limit=1",
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(config.get("DB_URL"));
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
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";
|
||||
|
||||
export class CreateShareDTO {
|
||||
@IsString()
|
||||
@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)
|
||||
id: string;
|
||||
@@ -13,6 +21,13 @@ export class CreateShareDTO {
|
||||
@IsString()
|
||||
expiration: string;
|
||||
|
||||
@MaxLength(512)
|
||||
@IsOptional()
|
||||
description: string;
|
||||
|
||||
@IsEmail({}, { each: true })
|
||||
recipients: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ShareSecurityDTO)
|
||||
security: ShareSecurityDTO;
|
||||
|
||||
@@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
recipients: string[];
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { FileDTO } from "src/file/dto/file.dto";
|
||||
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
|
||||
|
||||
export class ShareDTO {
|
||||
@Expose()
|
||||
@@ -14,8 +14,11 @@ export class ShareDTO {
|
||||
files: FileDTO[];
|
||||
|
||||
@Expose()
|
||||
@Type(() => AuthSignInDTO)
|
||||
creator: AuthSignInDTO;
|
||||
@Type(() => PublicUserDTO)
|
||||
creator: PublicUserDTO;
|
||||
|
||||
@Expose()
|
||||
description: string;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class SharePasswordDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export class ShareOwnerGuard implements CanActivate {
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
|
||||
if (!share.creatorId) return true;
|
||||
|
||||
return share.creatorId == (request.user as User).id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
"share_password_required"
|
||||
);
|
||||
|
||||
if (!this.shareService.verifyShareToken(shareId, shareToken))
|
||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||
throw new ForbiddenException(
|
||||
"Share token required",
|
||||
"share_token_required"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
@@ -20,7 +21,6 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Controller("shares")
|
||||
export class ShareController {
|
||||
constructor(private shareService: ShareService) {}
|
||||
@@ -70,6 +70,7 @@ export class ShareController {
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Throttle(10, 5 * 60)
|
||||
@UseGuards(ShareTokenSecurity)
|
||||
@Post(":id/token")
|
||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ShareController } from "./share.controller";
|
||||
import { ShareService } from "./share.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), forwardRef(() => FileModule)],
|
||||
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Share, User } from "@prisma/client";
|
||||
import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
@@ -20,11 +21,12 @@ export class ShareService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService,
|
||||
private emailService: EmailService,
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user: User) {
|
||||
async create(share: CreateShareDTO, user?: User) {
|
||||
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
|
||||
throw new BadRequestException("Share id already in use");
|
||||
|
||||
@@ -36,7 +38,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
let expirationDate;
|
||||
let expirationDate: Date;
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
@@ -58,8 +60,13 @@ export class ShareService {
|
||||
data: {
|
||||
...share,
|
||||
expiration: expirationDate,
|
||||
creator: { connect: { id: user.id } },
|
||||
creator: { connect: user ? { id: user.id } : undefined },
|
||||
security: { create: share.security },
|
||||
recipients: {
|
||||
create: share.recipients
|
||||
? share.recipients.map((email) => ({ email }))
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -84,20 +91,33 @@ export class ShareService {
|
||||
}
|
||||
|
||||
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))
|
||||
throw new BadRequestException("Share already completed");
|
||||
|
||||
const moreThanOneFileInShare =
|
||||
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
|
||||
|
||||
if (!moreThanOneFileInShare)
|
||||
if (share.files.length == 0)
|
||||
throw new BadRequestException(
|
||||
"You need at least on file in your share to complete it."
|
||||
);
|
||||
|
||||
this.createZip(id).then(() =>
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||
);
|
||||
// Asynchronously create a zip of all files
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
return await this.prisma.share.update({
|
||||
where: { id },
|
||||
@@ -106,7 +126,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async getSharesByUser(userId: string) {
|
||||
return await this.prisma.share.findMany({
|
||||
const shares = await this.prisma.share.findMany({
|
||||
where: {
|
||||
creator: { id: userId },
|
||||
uploadLocked: true,
|
||||
@@ -119,7 +139,17 @@ export class ShareService {
|
||||
orderBy: {
|
||||
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) {
|
||||
@@ -154,6 +184,8 @@ export class ShareService {
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
if (!share.creatorId)
|
||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
await this.prisma.share.delete({ where: { id: shareId } });
|
||||
|
||||
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,34 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
import { IsEmail, Length, Matches, MinLength } from "class-validator";
|
||||
|
||||
export class UserDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@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()
|
||||
lastName: string;
|
||||
|
||||
@Expose()
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
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 { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.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 { UserSevice } from "./user.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
constructor(private userService: UserSevice) {}
|
||||
|
||||
// Own user operations
|
||||
@Get("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user: 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": {
|
||||
"_postman_id": "243b0832-3a6a-4389-bb71-4d988c0a86d9",
|
||||
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a",
|
||||
"name": "Pingvin Share Testing",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "17822132"
|
||||
@@ -36,7 +36,7 @@
|
||||
"header": [],
|
||||
"body": {
|
||||
"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": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -97,7 +97,7 @@
|
||||
],
|
||||
"body": {
|
||||
"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\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -431,7 +431,7 @@
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" 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 +444,7 @@
|
||||
"header": [],
|
||||
"body": {
|
||||
"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": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -517,6 +517,60 @@
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Upload file 2",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test(\"Status code is 201\", () => {",
|
||||
" pm.response.to.have.status(201);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test(\"Response body correct\", () => {",
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||
"});"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": "./test/system/test-file.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||
"host": [
|
||||
"{{API_URL}}"
|
||||
],
|
||||
"path": [
|
||||
"shares",
|
||||
":shareId",
|
||||
"files"
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "shareId",
|
||||
"value": "test-share"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Complete share",
|
||||
"event": [
|
||||
@@ -532,7 +586,7 @@
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
@@ -942,9 +996,9 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"",
|
||||
"pm.test(\"Response contains 1 file\", () => {",
|
||||
"pm.test(\"Response contains 2 files\", () => {",
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody.files.length).be.equal(1)",
|
||||
" pm.expect(responseBody.files.length).be.equal(2)",
|
||||
"});",
|
||||
"",
|
||||
"",
|
||||
|
||||
@@ -5,11 +5,5 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- APP_URL=${APP_URL}
|
||||
- SHOW_HOME_PAGE=${SHOW_HOME_PAGE}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION}
|
||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
volumes:
|
||||
- "${PWD}/data:/opt/app/backend/data"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
SHOW_HOME_PAGE=true
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=1000000000
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /opt/app
|
||||
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /opt/app
|
||||
COPY . .
|
||||
COPY --from=deps /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /opt/app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /opt/app/next.config.js ./
|
||||
COPY --from=builder /opt/app/public ./public
|
||||
COPY --from=builder /opt/app/.next ./.next
|
||||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
|
||||
RUN npm i -g dotenv-cli
|
||||
|
||||
EXPOSE 3000
|
||||
CMD dotenv -e .env.development node_modules/.bin/next start
|
||||
@@ -1,17 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
publicRuntimeConfig: {
|
||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
|
||||
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
|
||||
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
const withPWA = require("next-pwa")({
|
||||
dest: "public",
|
||||
disable: process.env.NODE_ENV == "development"
|
||||
disable: process.env.NODE_ENV == "development",
|
||||
});
|
||||
|
||||
|
||||
module.exports = withPWA(nextConfig);
|
||||
module.exports = withPWA({ output: "standalone" });
|
||||
|
||||
3049
frontend/package-lock.json
generated
3049
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,47 +2,47 @@
|
||||
"name": "pingvin-share",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "dotenv next dev",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "dotenv next start",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.5.2",
|
||||
"@mantine/dropzone": "^5.5.2",
|
||||
"@mantine/form": "^5.5.2",
|
||||
"@mantine/hooks": "^5.5.2",
|
||||
"@mantine/modals": "^5.5.2",
|
||||
"@mantine/next": "^5.5.2",
|
||||
"@mantine/notifications": "^5.5.2",
|
||||
"axios": "^0.26.1",
|
||||
"cookies-next": "^2.0.4",
|
||||
"@mantine/core": "^5.9.2",
|
||||
"@mantine/dropzone": "^5.9.2",
|
||||
"@mantine/form": "^5.9.2",
|
||||
"@mantine/hooks": "^5.9.2",
|
||||
"@mantine/modals": "^5.9.2",
|
||||
"@mantine/next": "^5.9.2",
|
||||
"@mantine/notifications": "^5.9.2",
|
||||
"axios": "^1.2.0",
|
||||
"cookies-next": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.8.1",
|
||||
"moment": "^2.29.3",
|
||||
"next": "^12.3.1",
|
||||
"jose": "^4.11.1",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.0.6",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.4",
|
||||
"next-http-proxy-middleware": "^1.2.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "18.0.0",
|
||||
"react-dom": "18.0.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"p-limit": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.7.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.23",
|
||||
"@types/react": "18.0.4",
|
||||
"@types/react-dom": "18.0.0",
|
||||
"axios": "^0.26.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "8.13.0",
|
||||
"eslint-config-next": "12.1.5",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"axios": "^1.2.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-next": "^13.0.6",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"tar": "^6.1.11",
|
||||
"typescript": "^4.6.3"
|
||||
"prettier": "^2.8.0",
|
||||
"tar": "^6.1.12",
|
||||
"typescript": "^4.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Anchor, Center, Footer as MFooter, Text } from "@mantine/core";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<MFooter height="auto" p={10}>
|
||||
<Center>
|
||||
<Text size="xs" color="dimmed">
|
||||
Made with 🖤 by{" "}
|
||||
<Anchor size="xs" href="https://eliasschneider.com" target="_blank">
|
||||
Elias Schneider
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Center>
|
||||
</MFooter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
16
frontend/src/components/account/showShareLinkModal.tsx
Normal file
16
frontend/src/components/account/showShareLinkModal.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
|
||||
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => {
|
||||
const link = `${window.location.origin}/share/${shareId}`;
|
||||
return modals.openModal({
|
||||
title: "Share link",
|
||||
children: (
|
||||
<Stack align="stretch">
|
||||
<TextInput variant="filled" value={link} />
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export default showShareLinkModal;
|
||||
106
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
106
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Code,
|
||||
Group,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbEdit, TbLock } from "react-icons/tb";
|
||||
import configService from "../../services/config.service";
|
||||
import { AdminConfig as AdminConfigType } from "../../types/config.type";
|
||||
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
|
||||
|
||||
const AdminConfigTable = () => {
|
||||
const modals = useModals();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
|
||||
|
||||
const getConfigVariables = async () => {
|
||||
await configService.listForAdmin().then((configVariables) => {
|
||||
setConfigVariables(configVariables);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getConfigVariables().then(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const skeletonRows = [...Array(9)].map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Skeleton height={18} width={80} mb="sm" />
|
||||
<Skeleton height={30} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={18} />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Group position="right">
|
||||
<Skeleton height={25} width={25} />
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: configVariables.map((configVariable) => (
|
||||
<tr key={configVariable.key}>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
<Code>{configVariable.key}</Code>{" "}
|
||||
{configVariable.secret && <TbLock />} <br />
|
||||
<Text size="xs" color="dimmed">
|
||||
{configVariable.description}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
{configVariable.obscured
|
||||
? "•".repeat(configVariable.value.length)
|
||||
: configVariable.value}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<ActionIcon
|
||||
color="primary"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() =>
|
||||
showUpdateConfigVariableModal(
|
||||
modals,
|
||||
configVariable,
|
||||
getConfigVariables
|
||||
)
|
||||
}
|
||||
>
|
||||
<TbEdit />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfigTable;
|
||||
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;
|
||||
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;
|
||||
100
frontend/src/components/admin/showUpdateConfigVariableModal.tsx
Normal file
100
frontend/src/components/admin/showUpdateConfigVariableModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Button,
|
||||
Code,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import configService from "../../services/config.service";
|
||||
import { AdminConfig } from "../../types/config.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const showUpdateConfigVariableModal = (
|
||||
modals: ModalsContextProps,
|
||||
configVariable: AdminConfig,
|
||||
getConfigVariables: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Update configuration variable</Title>,
|
||||
children: (
|
||||
<Body
|
||||
configVariable={configVariable}
|
||||
getConfigVariables={getConfigVariables}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({
|
||||
configVariable,
|
||||
getConfigVariables,
|
||||
}: {
|
||||
configVariable: AdminConfig;
|
||||
getConfigVariables: () => void;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
stringValue: configVariable.value,
|
||||
numberValue: parseInt(configVariable.value),
|
||||
booleanValue: configVariable.value,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<Text>
|
||||
Set <Code>{configVariable.key}</Code> to
|
||||
</Text>
|
||||
{configVariable.type == "string" &&
|
||||
(configVariable.obscured ? (
|
||||
<PasswordInput label="Value" {...form.getInputProps("stringValue")} />
|
||||
) : (
|
||||
<TextInput label="Value" {...form.getInputProps("stringValue")} />
|
||||
))}
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput label="Value" {...form.getInputProps("numberValue")} />
|
||||
)}
|
||||
{configVariable.type == "boolean" && (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "true", label: "True" },
|
||||
{ value: "false", label: "False" },
|
||||
]}
|
||||
{...form.getInputProps("booleanValue")}
|
||||
/>
|
||||
)}
|
||||
<Space />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const value =
|
||||
configVariable.type == "string"
|
||||
? form.values.stringValue
|
||||
: configVariable.type == "number"
|
||||
? form.values.numberValue
|
||||
: form.values.booleanValue == "true";
|
||||
|
||||
await configService
|
||||
.update(configVariable.key, value)
|
||||
.then(() => {
|
||||
getConfigVariables();
|
||||
modals.closeAll();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default showUpdateConfigVariableModal;
|
||||
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;
|
||||
86
frontend/src/components/auth/SignInForm.tsx
Normal file
86
frontend/src/components/auth/SignInForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
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 validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
.signIn(email, password)
|
||||
.then(() => window.location.replace("/"))
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
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) =>
|
||||
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")}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInForm;
|
||||
@@ -9,23 +9,25 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
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({
|
||||
email: yup.string().email().required(),
|
||||
username: yup.string().min(3).required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
@@ -34,14 +36,14 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
.signIn(email, password)
|
||||
.then(() => window.location.replace("/upload"))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
.then(() => window.location.replace("/"))
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
const signUp = (email: string, password: string) => {
|
||||
const signUp = (email: string, username: string, password: string) => {
|
||||
authService
|
||||
.signUp(email, password)
|
||||
.signUp(email, username, password)
|
||||
.then(() => signIn(email, password))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
{mode == "signUp" ? "Sign up" : "Welcome back"}
|
||||
Sign up
|
||||
</Title>
|
||||
{publicRuntimeConfig.ALLOW_REGISTRATION == "true" && (
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
{mode == "signUp"
|
||||
? "You have an account already?"
|
||||
: "You don't have an account yet?"}{" "}
|
||||
<Anchor
|
||||
component={NextLink}
|
||||
href={mode == "signUp" ? "signIn" : "signUp"}
|
||||
size="sm"
|
||||
>
|
||||
{mode == "signUp" ? "Sign in" : "Sign up"}
|
||||
You have an account already?{" "}
|
||||
<Anchor component={Link} href={"signIn"} size="sm">
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
mode == "signIn"
|
||||
? signIn(values.email, values.password)
|
||||
: signUp(values.email, values.password)
|
||||
signUp(values.email, values.username, values.password)
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="john.doe"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@email.com"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
@@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
{mode == "signUp" ? "Let's get started" : "Sign in"}
|
||||
Let's get started
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
@@ -97,4 +97,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 { NextLink } from "@mantine/next";
|
||||
import { TbDoorExit, TbLink } from "react-icons/tb";
|
||||
import Link from "next/link";
|
||||
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
|
||||
const ActionAvatar = () => {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
@@ -13,12 +16,25 @@ const ActionAvatar = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
component={NextLink}
|
||||
component={Link}
|
||||
href="/account/shares"
|
||||
icon={<TbLink size={14} />}
|
||||
>
|
||||
My shares
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||
My account
|
||||
</Menu.Item>
|
||||
{user!.isAdmin && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/admin"
|
||||
icon={<TbSettings size={14} />}
|
||||
>
|
||||
Administration
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
onClick={async () => {
|
||||
authService.signOut();
|
||||
|
||||
@@ -11,18 +11,16 @@ import {
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import Logo from "../Logo";
|
||||
import ActionAvatar from "./ActionAvatar";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const HEADER_HEIGHT = 60;
|
||||
|
||||
type Link = {
|
||||
type NavLink = {
|
||||
link?: string;
|
||||
label?: string;
|
||||
component?: ReactNode;
|
||||
@@ -110,6 +108,8 @@ const useStyles = createStyles((theme) => ({
|
||||
|
||||
const NavBar = () => {
|
||||
const user = useUser();
|
||||
const config = useConfig();
|
||||
|
||||
const [opened, toggleOpened] = useDisclosure(false);
|
||||
|
||||
const authenticatedLinks = [
|
||||
@@ -122,7 +122,7 @@ const NavBar = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<Link[]>([
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
@@ -130,7 +130,7 @@ const NavBar = () => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicRuntimeConfig.SHOW_HOME_PAGE == "true")
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
{
|
||||
link: "/",
|
||||
@@ -139,7 +139,7 @@ const NavBar = () => {
|
||||
...array,
|
||||
]);
|
||||
|
||||
if (publicRuntimeConfig.ALLOW_REGISTRATION == "true")
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
...array,
|
||||
{
|
||||
@@ -161,7 +161,7 @@ const NavBar = () => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NextLink
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.link ?? ""}
|
||||
onClick={() => toggleOpened.toggle()}
|
||||
@@ -170,7 +170,7 @@ const NavBar = () => {
|
||||
})}
|
||||
>
|
||||
{link.label}
|
||||
</NextLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -178,12 +178,12 @@ const NavBar = () => {
|
||||
return (
|
||||
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||
<Container className={classes.header}>
|
||||
<NextLink href="/">
|
||||
<Link href="/" passHref>
|
||||
<Group>
|
||||
<Logo height={35} width={35} />
|
||||
<Text weight={600}>Pingvin Share</Text>
|
||||
</Group>
|
||||
</NextLink>
|
||||
</Link>
|
||||
<Group spacing={5} className={classes.links}>
|
||||
<Group>{items} </Group>
|
||||
</Group>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import moment from "moment";
|
||||
import * as yup from "yup";
|
||||
import shareService from "../../services/share.service";
|
||||
import { ShareSecurity } from "../../types/share.type";
|
||||
|
||||
const PreviewExpiration = ({ form }: { form: any }) => {
|
||||
const value = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
if (value === "never") return "This share will never expire.";
|
||||
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
value.split("-")[0],
|
||||
value.split("-")[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
|
||||
};
|
||||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
}: {
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
security: ShareSecurity
|
||||
) => void;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const validationSchema = yup.object().shape({
|
||||
link: yup
|
||||
.string()
|
||||
.required()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||
message: "Can only contain letters, numbers, underscores and hyphens",
|
||||
}),
|
||||
password: yup.string().min(3).max(30),
|
||||
maxViews: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
|
||||
password: undefined,
|
||||
maxViews: undefined,
|
||||
expiration_num: 1,
|
||||
expiration_unit: "-days",
|
||||
never_expires: false,
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||
form.setFieldError("link", "This link is already in use");
|
||||
} else {
|
||||
const expiration = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
uploadCallback(values.link, expiration, {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
});
|
||||
modals.closeAll();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack align="stretch">
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Link"
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{window.location.origin}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
</Text>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label: "Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label: "Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label: "Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label: "Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
|
||||
{/* Preview expiration date text */}
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{PreviewExpiration({ form })}
|
||||
</Text>
|
||||
|
||||
<Accordion>
|
||||
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Security options</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
{...form.getInputProps("maxViews")}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUploadModalBody;
|
||||
@@ -9,52 +9,10 @@ const FileList = ({
|
||||
shareId,
|
||||
isLoading,
|
||||
}: {
|
||||
files: any[];
|
||||
files?: any[];
|
||||
shareId: string;
|
||||
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 (
|
||||
<Table>
|
||||
<thead>
|
||||
@@ -64,9 +22,53 @@ const FileList = ({
|
||||
<th></th>
|
||||
</tr>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
||||
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
||||
import getConfig from "next/config";
|
||||
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
||||
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
@@ -40,27 +38,25 @@ const Dropzone = ({
|
||||
isUploading: boolean;
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
||||
const { classes } = useStyles();
|
||||
const openRef = useRef<() => void>();
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<MantineDropzone
|
||||
maxSize={parseInt(publicRuntimeConfig.MAX_FILE_SIZE!)}
|
||||
maxSize={parseInt(config.get("MAX_FILE_SIZE"))}
|
||||
onReject={(e) => {
|
||||
toast.error(e[0].errors[0].message);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
openRef={openRef as ForwardedRef<() => void>}
|
||||
onDrop={(files) => {
|
||||
if (files.length > 100) {
|
||||
toast.error("You can't upload more than 100 files per share.");
|
||||
} else {
|
||||
const newFiles = files.map((file) => {
|
||||
(file as FileUpload).uploadingProgress = 0;
|
||||
return file as FileUpload;
|
||||
});
|
||||
setFiles(newFiles);
|
||||
}
|
||||
const newFiles = files.map((file) => {
|
||||
(file as FileUpload).uploadingProgress = 0;
|
||||
return file as FileUpload;
|
||||
});
|
||||
setFiles(newFiles);
|
||||
}}
|
||||
className={classes.dropzone}
|
||||
radius="md"
|
||||
@@ -75,8 +71,7 @@ const Dropzone = ({
|
||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||
Drag'n'drop files here to start your share. We can accept
|
||||
only files that are less than{" "}
|
||||
{byteStringToHumanSizeString(publicRuntimeConfig.MAX_FILE_SIZE)} in
|
||||
size.
|
||||
{byteStringToHumanSizeString(config.get("MAX_FILE_SIZE"))} in size.
|
||||
</Text>
|
||||
</div>
|
||||
</MantineDropzone>
|
||||
|
||||
19
frontend/src/components/upload/ExpirationPreview.tsx
Normal file
19
frontend/src/components/upload/ExpirationPreview.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import moment from "moment";
|
||||
|
||||
const ExpirationPreview = ({ form }: { form: any }) => {
|
||||
const value = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
if (value === "never") return "This share will never expire.";
|
||||
|
||||
const expirationDate = moment()
|
||||
.add(
|
||||
value.split("-")[0],
|
||||
value.split("-")[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
|
||||
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
|
||||
};
|
||||
|
||||
export default ExpirationPreview;
|
||||
@@ -10,13 +10,10 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { Share } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
import { Share } from "../../../types/share.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
||||
return modals.openModal({
|
||||
@@ -43,14 +40,16 @@ const Body = ({ share }: { share: Share }) => {
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
window.isSecureContext && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
301
frontend/src/components/upload/modals/showCreateUploadModal.tsx
Normal file
301
frontend/src/components/upload/modals/showCreateUploadModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useState } from "react";
|
||||
import { TbAlertCircle } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { CreateShare } from "../../../types/share.type";
|
||||
import ExpirationPreview from "../ExpirationPreview";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
},
|
||||
uploadCallback: (createShare: CreateShare) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
options={options}
|
||||
uploadCallback={uploadCallback}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
options,
|
||||
}: {
|
||||
uploadCallback: (createShare: CreateShare) => void;
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
};
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
link: yup
|
||||
.string()
|
||||
.required()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||
message: "Can only contain letters, numbers, underscores and hyphens",
|
||||
}),
|
||||
password: yup.string().min(3).max(30),
|
||||
maxViews: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
recipients: [] as string[],
|
||||
password: undefined,
|
||||
maxViews: undefined,
|
||||
description: undefined,
|
||||
expiration_num: 1,
|
||||
expiration_unit: "-days",
|
||||
never_expires: false,
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
return (
|
||||
<Group>
|
||||
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||
<Alert
|
||||
withCloseButton
|
||||
onClose={() => setShowNotSignedInAlert(false)}
|
||||
icon={<TbAlertCircle size={16} />}
|
||||
title="You're not signed in"
|
||||
color="yellow"
|
||||
>
|
||||
You will be unable to delete your share manually and view the visitor
|
||||
count.
|
||||
</Alert>
|
||||
)}
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||
form.setFieldError("link", "This link is already in use");
|
||||
} else {
|
||||
const expiration = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
uploadCallback({
|
||||
id: values.link,
|
||||
expiration: expiration,
|
||||
recipients: values.recipients,
|
||||
description: values.description,
|
||||
security: {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
},
|
||||
});
|
||||
modals.closeAll();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack align="stretch">
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Link"
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{window.location.origin}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
</Text>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<Select
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_unit")}
|
||||
data={[
|
||||
// Set the label to singular if the number is 1, else plural
|
||||
{
|
||||
value: "-minutes",
|
||||
label:
|
||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-hours",
|
||||
label:
|
||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-days",
|
||||
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-weeks",
|
||||
label:
|
||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-months",
|
||||
label:
|
||||
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
{
|
||||
value: "-years",
|
||||
label:
|
||||
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
{/* Preview expiration date text */}
|
||||
<Text
|
||||
italic
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{ExpirationPreview({ form })}
|
||||
</Text>
|
||||
<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.Control>Security options</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
{...form.getInputProps("maxViews")}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default showCreateUploadModal;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Title } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { ShareSecurity } from "../../types/share.type";
|
||||
import CreateUploadModalBody from "../share/CreateUploadModalBody";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
security: ShareSecurity
|
||||
) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
children: <CreateUploadModalBody uploadCallback={uploadCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
export default showCreateUploadModal;
|
||||
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;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import Meta from "../components/Meta";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import Link from "next/link";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
@@ -53,7 +53,7 @@ const ErrorNotFound = () => {
|
||||
className={classes.description}
|
||||
></Text>
|
||||
<Group position="center">
|
||||
<Button component={NextLink} href="/" variant="light">
|
||||
<Button component={Link} href="/" variant="light">
|
||||
Bring me back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -3,32 +3,38 @@ import {
|
||||
Container,
|
||||
LoadingOverlay,
|
||||
MantineProvider,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Footer from "../components/Footer";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import useConfig, { ConfigContext } from "../hooks/config.hook";
|
||||
import { UserContext } from "../hooks/user.hook";
|
||||
import authService from "../services/auth.service";
|
||||
import configService from "../services/config.service";
|
||||
import userService from "../services/user.service";
|
||||
import GlobalStyle from "../styles/global.style";
|
||||
import globalStyle from "../styles/mantine.style";
|
||||
import Config from "../types/config.type";
|
||||
import { CurrentUser } from "../types/user.type";
|
||||
import { GlobalLoadingContext } from "../utils/loading.util";
|
||||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const systemTheme = useColorScheme();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
||||
|
||||
const getInitalData = async () => {
|
||||
setIsLoading(true);
|
||||
setConfigVariables(await configService.list());
|
||||
await authService.refreshAccessToken();
|
||||
setUser(await userService.getCurrentUser());
|
||||
setIsLoading(false);
|
||||
@@ -39,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
|
||||
getInitalData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
configVariables &&
|
||||
configVariables.filter((variable) => variable.key)[0].value == "false" &&
|
||||
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
|
||||
) {
|
||||
router.push(!user ? "/auth/signUp" : "/admin/setup");
|
||||
}
|
||||
}, [router.asPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(systemTheme);
|
||||
}, [systemTheme]);
|
||||
@@ -56,18 +72,15 @@ function App({ Component, pageProps }: AppProps) {
|
||||
{isLoading ? (
|
||||
<LoadingOverlay visible overlayOpacity={1} />
|
||||
) : (
|
||||
<UserContext.Provider value={user}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Stack justify="space-between" sx={{ minHeight: "100vh" }}>
|
||||
<div>
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</div>
|
||||
<Footer />
|
||||
</Stack>
|
||||
</UserContext.Provider>
|
||||
<ConfigContext.Provider value={configVariables}>
|
||||
<UserContext.Provider value={user} >
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>{" "}
|
||||
</ConfigContext.Provider>
|
||||
)}
|
||||
</GlobalLoadingContext.Provider>
|
||||
</ModalsProvider>
|
||||
|
||||
150
frontend/src/pages/account/index.tsx
Normal file
150
frontend/src/pages/account/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useRouter } from "next/router";
|
||||
import * as yup from "yup";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import userService from "../../services/user.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Account = () => {
|
||||
const user = useUser();
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
username: user?.username,
|
||||
email: user?.email,
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
initialValues: {
|
||||
oldPassword: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
oldPassword: yup.string().min(8),
|
||||
password: yup.string().min(8),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm">
|
||||
<Title order={3} mb="xs">
|
||||
My account
|
||||
</Title>
|
||||
<Paper withBorder p="xl">
|
||||
<Title order={5} mb="xs">
|
||||
Account Info
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={accountForm.onSubmit((values) =>
|
||||
userService
|
||||
.updateCurrentUser({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
})
|
||||
.then(() => toast.success("User updated successfully"))
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput label="Email" {...accountForm.getInputProps("email")} />
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Password
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit((values) =>
|
||||
authService
|
||||
.updatePassword(values.oldPassword, values.password)
|
||||
.then(() => {
|
||||
toast.success("Password updated successfully");
|
||||
passwordForm.reset();
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Old password"
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Center mt={80}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
modals.openConfirmModal({
|
||||
title: "Account deletion",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete your account including all your
|
||||
active shares?
|
||||
</Text>
|
||||
),
|
||||
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await userService.removeCurrentUser();
|
||||
window.location.reload();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
||||
@@ -12,20 +12,18 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import moment from "moment";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbLink, TbTrash } from "react-icons/tb";
|
||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||
import Meta from "../../components/Meta";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { MyShare } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const MyShares = () => {
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
@@ -54,7 +52,7 @@ const MyShares = () => {
|
||||
<Title order={3}>It's empty here 👀</Title>
|
||||
<Text>You don't have any shares.</Text>
|
||||
<Space h={5} />
|
||||
<Button component={NextLink} href="/upload" variant="light">
|
||||
<Button component={Link} href="/upload" variant="light">
|
||||
Create one
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -86,12 +84,16 @@ const MyShares = () => {
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
clipboard.copy(
|
||||
`${window.location.origin}/share/${share.id}`
|
||||
);
|
||||
toast.success(
|
||||
"Your link was copied to the keyboard."
|
||||
);
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${window.location.origin}/share/${share.id}`
|
||||
);
|
||||
toast.success(
|
||||
"Your link was copied to the keyboard."
|
||||
);
|
||||
} else {
|
||||
showShareLinkModal(modals, share.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbLink />
|
||||
|
||||
16
frontend/src/pages/admin/config.tsx
Normal file
16
frontend/src/pages/admin/config.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Space, Title } from "@mantine/core";
|
||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||
|
||||
const AdminConfig = () => {
|
||||
return (
|
||||
<>
|
||||
<Title mb={30} order={3}>
|
||||
Configuration
|
||||
</Title>
|
||||
<AdminConfigTable />
|
||||
<Space h="xl" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfig;
|
||||
60
frontend/src/pages/admin/index.tsx
Normal file
60
frontend/src/pages/admin/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Col, createStyles, Grid, Paper, Text } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { TbSettings, TbUsers } from "react-icons/tb";
|
||||
|
||||
const managementOptions = [
|
||||
{
|
||||
title: "User management",
|
||||
icon: TbUsers,
|
||||
route: "/admin/users",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
icon: TbSettings,
|
||||
route: "/admin/config",
|
||||
},
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: 90,
|
||||
"&:hover": {
|
||||
boxShadow: `${theme.shadows.sm} !important`,
|
||||
transform: "scale(1.01)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const Admin = () => {
|
||||
const { classes, theme } = useStyles();
|
||||
|
||||
return (
|
||||
<Paper withBorder p={40}>
|
||||
<Grid mt="md">
|
||||
{managementOptions.map((item) => {
|
||||
return (
|
||||
<Col xs={6} key={item.route}>
|
||||
<Paper
|
||||
withBorder
|
||||
component={Link}
|
||||
href={item.route}
|
||||
key={item.title}
|
||||
className={classes.item}
|
||||
>
|
||||
<item.icon color={theme.colors.victoria[8]} size={35} />
|
||||
<Text mt={7}>{item.title}</Text>
|
||||
</Paper>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
52
frontend/src/pages/admin/setup.tsx
Normal file
52
frontend/src/pages/admin/setup.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Box, Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||
import Logo from "../../components/Logo";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import configService from "../../services/config.service";
|
||||
|
||||
const Setup = () => {
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
const user = useUser();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
router.push("/auth/signUp");
|
||||
return;
|
||||
} else if (config.get("SETUP_FINISHED")) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack align="center">
|
||||
<Logo height={80} width={80} />
|
||||
<Title order={2}>Welcome to Pingvin Share</Title>
|
||||
<Text>Let's customize Pingvin Share for you! </Text>
|
||||
<Box style={{ width: "100%" }}>
|
||||
<AdminConfigTable />
|
||||
</Box>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
await configService.finishSetup();
|
||||
setIsLoading(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
mb={70}
|
||||
mt="lg"
|
||||
>
|
||||
Let me in
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
73
frontend/src/pages/admin/users.tsx
Normal file
73
frontend/src/pages/admin/users.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button, Group, Space, Text, Title } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbPlus } from "react-icons/tb";
|
||||
import ManageUserTable from "../../components/admin/ManageUserTable";
|
||||
import showCreateUserModal from "../../components/admin/showCreateUserModal";
|
||||
import userService from "../../services/user.service";
|
||||
import User from "../../types/user.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Users = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const modals = useModals();
|
||||
|
||||
const getUsers = () => {
|
||||
setIsLoading(true);
|
||||
userService.list().then((users) => {
|
||||
setUsers(users);
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (user: User) => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${user.username}?`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete <b>{user.username}</b> and all his
|
||||
shares?
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
userService
|
||||
.remove(user.id)
|
||||
.then(() => setUsers(users.filter((v) => v.id != user.id)))
|
||||
.catch(toast.axiosError);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group position="apart" align="baseline" mb={20}>
|
||||
<Title mb={30} order={3}>
|
||||
User management
|
||||
</Title>
|
||||
<Button
|
||||
onClick={() => showCreateUserModal(modals, getUsers)}
|
||||
leftIcon={<TbPlus size={20} />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ManageUserTable
|
||||
users={users}
|
||||
getUsers={getUsers}
|
||||
deleteUser={deleteUser}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Space h="xl" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@@ -8,9 +8,11 @@ export const config = {
|
||||
},
|
||||
};
|
||||
|
||||
// This function can be marked `async` if using `await` inside
|
||||
export default (req: NextApiRequest, res: NextApiResponse) =>
|
||||
httpProxyMiddleware(req, res, {
|
||||
// You can use the `http-proxy` option
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return httpProxyMiddleware(req, res, {
|
||||
headers: {
|
||||
"X-Forwarded-For": req.socket.remoteAddress ?? "",
|
||||
},
|
||||
target: "http://localhost:8080",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import SignInForm from "../../components/auth/SignInForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
@@ -12,7 +12,7 @@ const SignIn = () => {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign In" />
|
||||
<AuthForm mode="signIn" />
|
||||
<SignInForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import SignUpForm from "../../components/auth/SignUpForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const SignUp = () => {
|
||||
const config = useConfig();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
} else if (publicRuntimeConfig.ALLOW_REGISTRATION == "false") {
|
||||
} else if (!config.get("ALLOW_REGISTRATION")) {
|
||||
router.replace("/auth/signIn");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign Up" />
|
||||
<AuthForm mode="signUp" />
|
||||
<SignUpForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user