Compare commits
15 Commits
v1.0.0
...
link-copy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fa76563e | ||
|
|
f96ac5e4ba | ||
|
|
bbbf10d233 | ||
|
|
e9d1a9abb6 | ||
|
|
8fdba0ca7c | ||
|
|
e5718700bc | ||
|
|
e40a0c844c | ||
|
|
e647746c93 | ||
|
|
9be77826e9 | ||
|
|
a9bb05c4da | ||
|
|
e1a9f2a27c | ||
|
|
ba62c13cfa | ||
|
|
3de744d5e9 | ||
|
|
61608cfe2d | ||
|
|
db755ef300 |
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: 💬 Discord
|
|
||||||
url: https://discord.gg/wHRQ9nFRcK
|
|
||||||
about: For help and chatting with the community
|
|
||||||
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
name: "🌐 Language request"
|
|
||||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
|
||||||
title: "🌐 Language request: <language name in english>"
|
|
||||||
labels: [language-request]
|
|
||||||
body:
|
|
||||||
- type: input
|
|
||||||
id: language-name-native
|
|
||||||
attributes:
|
|
||||||
label: "🌐 Language name (native)"
|
|
||||||
placeholder: "Schweizerdeutsch"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: language-code
|
|
||||||
attributes:
|
|
||||||
label: "🌐 Language code"
|
|
||||||
placeholder: "de-CH"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
17
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: ❓ Question
|
||||||
|
description: "Submit a question"
|
||||||
|
title: "❓ Question:"
|
||||||
|
labels: [question]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🙋♂️ Question"
|
||||||
|
description: "A clear question. Please provide as much detail as possible."
|
||||||
|
placeholder: "How do I ...?"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting, please check if the question hasn't been asked before.
|
||||||
28
.github/workflows/build-docker-image.yml
vendored
28
.github/workflows/build-docker-image.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Docker Image
|
name: Create Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -10,25 +10,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
|
- name: login to docker registry
|
||||||
- name: Login to Docker registry
|
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
uses: docker/login-action@v2
|
- name: Build the image
|
||||||
with:
|
run: |
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
docker buildx build --push \
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
--tag stonith404/pingvin-share:latest \
|
||||||
|
--tag stonith404/pingvin-share:${{ github.ref_name }} \
|
||||||
- name: Build and push
|
--platform linux/amd64,linux/arm64 .
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
23
.github/workflows/close_inactive_issues.yml
vendored
Normal file
23
.github/workflows/close_inactive_issues.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Close inactive issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "00 00 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issues:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v4
|
||||||
|
with:
|
||||||
|
days-before-issue-stale: 30
|
||||||
|
days-before-issue-close: 14
|
||||||
|
exempt-issue-labels: "feature"
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||||
|
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -38,9 +38,6 @@ yarn-error.log*
|
|||||||
# project specific
|
# project specific
|
||||||
/backend/data/
|
/backend/data/
|
||||||
/data/
|
/data/
|
||||||
/docs/build/
|
|
||||||
/docs/.docusaurus
|
|
||||||
/docs/.cache-loader
|
|
||||||
|
|
||||||
# Jetbrains specific (webstorm)
|
# Jetbrains specific (webstorm)
|
||||||
.idea/**/**
|
.idea/**/**
|
||||||
|
|||||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/backend/src/constants.ts
|
||||||
363
CHANGELOG.md
363
CHANGELOG.md
@@ -1,366 +1,3 @@
|
|||||||
## [1.0.0](https://github.com/stonith404/pingvin-share/compare/v0.29.0...v1.0.0) (2024-08-25)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **ldap:** Adding support for LDAP authentication ([#554](https://github.com/stonith404/pingvin-share/issues/554)) ([4186a76](https://github.com/stonith404/pingvin-share/commit/4186a768b310855282bc4876d1f294700963b8f5))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* get started button on home page not working when sign-up is disabled ([4924f76](https://github.com/stonith404/pingvin-share/commit/4924f763947c9a6b79ba0d85887f104ed9545c78))
|
|
||||||
* internal server error if user has no password when trying to sign in ([9c381a2](https://github.com/stonith404/pingvin-share/commit/9c381a2ed6b3b7dfd95d4278889b937ffb85e01b))
|
|
||||||
|
|
||||||
## [0.29.0](https://github.com/stonith404/pingvin-share/compare/v0.28.0...v0.29.0) (2024-07-30)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add more options to reverse shares ([#495](https://github.com/stonith404/pingvin-share/issues/495)) ([fe735f9](https://github.com/stonith404/pingvin-share/commit/fe735f9704c9d96398f3127a559e17848b08d140)), closes [#155](https://github.com/stonith404/pingvin-share/issues/155)
|
|
||||||
* sort share files by name by default ([27ee9fb](https://github.com/stonith404/pingvin-share/commit/27ee9fb6cb98177661bed20a0baa399b27e70b7e))
|
|
||||||
|
|
||||||
|
|
||||||
### Reverts
|
|
||||||
|
|
||||||
* Revert "fix: set max age of access token cookie to 15 minutes" ([14c2185](https://github.com/stonith404/pingvin-share/commit/14c2185e6f1a81d63e25fbeec3e30a54cf6a44c5))
|
|
||||||
|
|
||||||
## [0.28.0](https://github.com/stonith404/pingvin-share/compare/v0.27.0...v0.28.0) (2024-07-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **auth:** Add role-based access management from OpenID Connect ([#535](https://github.com/stonith404/pingvin-share/issues/535)) ([70fd2d9](https://github.com/stonith404/pingvin-share/commit/70fd2d94be3411cc430f5c56e522028398127efb))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* store only 10 share tokens in the cookies and clear the expired ones ([e5a0c64](https://github.com/stonith404/pingvin-share/commit/e5a0c649e36e0db419d04446affe2564c45cf321))
|
|
||||||
|
|
||||||
## [0.27.0](https://github.com/stonith404/pingvin-share/compare/v0.26.0...v0.27.0) (2024-07-11)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add logs for successful registration, successful login and failed login ([d2bfb9a](https://github.com/stonith404/pingvin-share/commit/d2bfb9a55fdad6a05377b8552471cf1151304c90))
|
|
||||||
* **auth:** Allow to hide username / password login form when OAuth is enabled ([#518](https://github.com/stonith404/pingvin-share/issues/518)) ([e1a68f7](https://github.com/stonith404/pingvin-share/commit/e1a68f75f7b034f1ef9e45f26de584f13e355589)), closes [#489](https://github.com/stonith404/pingvin-share/issues/489)
|
|
||||||
* **smtp:** allow unauthorized mail server certificates ([#525](https://github.com/stonith404/pingvin-share/issues/525)) ([083d82c](https://github.com/stonith404/pingvin-share/commit/083d82c28b835c178f076e89ef8f5885e8ea31cb))
|
|
||||||
|
|
||||||
## [0.26.0](https://github.com/stonith404/pingvin-share/compare/v0.25.0...v0.26.0) (2024-07-03)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **backend:** Make session duration configurable ([#512](https://github.com/stonith404/pingvin-share/issues/512)) ([367f804](https://github.com/stonith404/pingvin-share/commit/367f804a494c85b4caf879d51982339fb6b86ba1)), closes [#507](https://github.com/stonith404/pingvin-share/issues/507)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **oauth:** provider username is ignored when signing up using OAuth ([#511](https://github.com/stonith404/pingvin-share/issues/511)) ([31366d9](https://github.com/stonith404/pingvin-share/commit/31366d961f5827c200038b65ec9de5d4ddc8b898)), closes [#505](https://github.com/stonith404/pingvin-share/issues/505)
|
|
||||||
* set max age of access token cookie to 15 minutes ([2dac385](https://github.com/stonith404/pingvin-share/commit/2dac38560b6c54b6e7676dcd4682bfa57973292f))
|
|
||||||
|
|
||||||
## [0.25.0](https://github.com/stonith404/pingvin-share/compare/v0.24.2...v0.25.0) (2024-06-10)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add auto open share modal config for global. ([#474](https://github.com/stonith404/pingvin-share/issues/474)) ([4fd2903](https://github.com/stonith404/pingvin-share/commit/4fd29037a08dbe505bdd8cf20f6f114cbade8483))
|
|
||||||
* **frontend:** locale for dates and tooltip for copy link button ([#492](https://github.com/stonith404/pingvin-share/issues/492)) ([1c7832a](https://github.com/stonith404/pingvin-share/commit/1c7832ad1fb445fd1dbe1c111be5a331eaa4b797))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* share size not displayed on my shares page ([c0cc16f](https://github.com/stonith404/pingvin-share/commit/c0cc16fa430bc64afb024c19d5faf24456bd417c))
|
|
||||||
|
|
||||||
## [0.24.2](https://github.com/stonith404/pingvin-share/compare/v0.24.1...v0.24.2) (2024-05-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* admin couldn't delete shares created by anonymous users ([7afda85](https://github.com/stonith404/pingvin-share/commit/7afda85f03d410a6c611860d0c3fb2b88a2e3679))
|
|
||||||
* whitespace in title on homepage ([74cd520](https://github.com/stonith404/pingvin-share/commit/74cd520cb8c4ea87822ab6d54c0bf010455f401b))
|
|
||||||
|
|
||||||
## [0.24.1](https://github.com/stonith404/pingvin-share/compare/v0.24.0...v0.24.1) (2024-05-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* error on admin share management page if a share was created by an anonymous user ([c999df1](https://github.com/stonith404/pingvin-share/commit/c999df15e04a927f6e952db3c807b9591fb14894))
|
|
||||||
|
|
||||||
## [0.24.0](https://github.com/stonith404/pingvin-share/compare/v0.23.1...v0.24.0) (2024-05-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add admin-exclusive share-management page ([#461](https://github.com/stonith404/pingvin-share/issues/461)) ([3b1c9f1](https://github.com/stonith404/pingvin-share/commit/3b1c9f1efb7d02469e92537a2d1378b6cb412878))
|
|
||||||
* add name property to share ([#462](https://github.com/stonith404/pingvin-share/issues/462)) ([b717663](https://github.com/stonith404/pingvin-share/commit/b717663b5c3a4a98e361e7e39b680f4852537c59))
|
|
||||||
|
|
||||||
## [0.23.1](https://github.com/stonith404/pingvin-share/compare/v0.23.0...v0.23.1) (2024-04-05)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **backend:** crash on unhandled promise rejections ([1da4fee](https://github.com/stonith404/pingvin-share/commit/1da4feeb895a13d0a0ae754bd716a84e8186d081))
|
|
||||||
* changing the chunk size needed an app restart ([24e100b](https://github.com/stonith404/pingvin-share/commit/24e100bd7be8bf20778bdf2767aa35cae8d7e502))
|
|
||||||
* disable js execution on raw file view ([9d1a12b](https://github.com/stonith404/pingvin-share/commit/9d1a12b0d1812214f1fe6fa56e3848091ce4945c))
|
|
||||||
* incorrect layout on 404 page ([3c5e0ad](https://github.com/stonith404/pingvin-share/commit/3c5e0ad5134ee2d405ac420152b5825102f65bfc))
|
|
||||||
* normal shares were added to the previous reverse share ([3972589](https://github.com/stonith404/pingvin-share/commit/3972589f76519b03074d916fb2460c795b1f0737))
|
|
||||||
* redirect vulnerability on error, sign in and totp page ([384fd19](https://github.com/stonith404/pingvin-share/commit/384fd19203b63eeb4b952f83a9e1eaab1b19b90d))
|
|
||||||
|
|
||||||
## [0.23.0](https://github.com/stonith404/pingvin-share/compare/v0.22.2...v0.23.0) (2024-04-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* add config variable to adjust chunk size ([0bfbaea](https://github.com/stonith404/pingvin-share/commit/0bfbaea49aad0c695fee6558c89c661687912e4f))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* delete share files if user gets deleted ([e71f6cd](https://github.com/stonith404/pingvin-share/commit/e71f6cd1598ed87366074398042a6b88675587ca))
|
|
||||||
* error in logs if "allow unauthenticated shares" is enabled ([c6d8188](https://github.com/stonith404/pingvin-share/commit/c6d8188e4e33ba682551a3ca79205ff5a6d7ead5))
|
|
||||||
* memory leak while uploading files by disabling base64 encoding of chunks ([7a15fbb](https://github.com/stonith404/pingvin-share/commit/7a15fbb4651c2fee32fb4c1ee2c9d7f12323feb0))
|
|
||||||
|
|
||||||
## [0.22.2](https://github.com/stonith404/pingvin-share/compare/v0.22.1...v0.22.2) (2024-02-29)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* extend access token cookie expiration ([013b988](https://github.com/stonith404/pingvin-share/commit/013b9886af5629b2ead6000b962267afc761c612))
|
|
||||||
* reduce refresh access token calls ([1aa3d8e](https://github.com/stonith404/pingvin-share/commit/1aa3d8e5e89b3696cc9554f41e9ce13806dde406))
|
|
||||||
* replace Nginx with Caddy to fix "premature close" error while downloading larger files ([43bff91](https://github.com/stonith404/pingvin-share/commit/43bff91db2ba4ec68d76e601f7bc42cb7a506bc5))
|
|
||||||
|
|
||||||
## [0.22.1](https://github.com/stonith404/pingvin-share/compare/v0.22.0...v0.22.1) (2024-02-18)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* back links on error modals ([f52dffd](https://github.com/stonith404/pingvin-share/commit/f52dffdaac5a893804525913943f3f4f99b7c55a))
|
|
||||||
* prevent zoom on input field click on mobile ([9c734ec](https://github.com/stonith404/pingvin-share/commit/9c734ec439aeaeebe172caa41bf531e6d8b3fac3))
|
|
||||||
* replace middleware backend url with local backend url ([76df6f6](https://github.com/stonith404/pingvin-share/commit/76df6f66d965dd751146468abfafb0c6acd46310))
|
|
||||||
* user `id` and `totpVerified` can't be changed by user ([e663da4](https://github.com/stonith404/pingvin-share/commit/e663da45b1d15f5e6e33118e6a28e1504688034c))
|
|
||||||
* user enumaration on forgot password page ([64515d7](https://github.com/stonith404/pingvin-share/commit/64515d77cfc116a243d78610395ccc383ba62940))
|
|
||||||
|
|
||||||
## [0.22.0](https://github.com/stonith404/pingvin-share/compare/v0.21.5...v0.22.0) (2024-02-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **translations:** typo in string ([c189cd9](https://github.com/stonith404/pingvin-share/commit/c189cd97a502cee8ea79e5187d9288d636d4983c))
|
|
||||||
|
|
||||||
## [0.21.5](https://github.com/stonith404/pingvin-share/compare/v0.21.4...v0.21.5) (2024-01-14)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* password can be changed with wrong password ([0ccb836](https://github.com/stonith404/pingvin-share/commit/0ccb8364448d27ea07c8b11972ff454d610893c6))
|
|
||||||
|
|
||||||
## [0.21.4](https://github.com/stonith404/pingvin-share/compare/v0.21.3...v0.21.4) (2024-01-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **frontend:** add navigateToLink button for CopyTextField. close [#372](https://github.com/stonith404/pingvin-share/issues/372). ([#376](https://github.com/stonith404/pingvin-share/issues/376)) ([d775008](https://github.com/stonith404/pingvin-share/commit/d7750086b5b796cfc70d8dc0c7d0ab4bd1996ca0))
|
|
||||||
|
|
||||||
## [0.21.3](https://github.com/stonith404/pingvin-share/compare/v0.21.2...v0.21.3) (2024-01-02)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* don't show validation error on upload modal if password or max views are empty ([fe09d0e](https://github.com/stonith404/pingvin-share/commit/fe09d0e25f6fbfc4e1c9302054d3387fe8b1f0ea))
|
|
||||||
|
|
||||||
## [0.21.2](https://github.com/stonith404/pingvin-share/compare/v0.21.1...v0.21.2) (2023-12-29)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* missing logo images on fresh installation ([6fb31ab](https://github.com/stonith404/pingvin-share/commit/6fb31abd84b22cd464b6b45bf7ca6f83853e8720))
|
|
||||||
* missing translations on reset password page ([7a301b4](https://github.com/stonith404/pingvin-share/commit/7a301b455cdea4b1dbc04cc6223e094fee9aca7b))
|
|
||||||
|
|
||||||
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
|
|
||||||
|
|
||||||
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
|
|
||||||
|
|
||||||
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
|
|
||||||
|
|
||||||
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
|
|
||||||
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
|
|
||||||
|
|
||||||
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
|
|
||||||
|
|
||||||
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
|
|
||||||
|
|
||||||
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
|
|
||||||
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
|
|
||||||
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
|
|
||||||
|
|
||||||
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
|
|
||||||
|
|
||||||
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
|
|
||||||
|
|
||||||
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
|
|
||||||
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
|
|
||||||
|
|
||||||
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
|
|
||||||
|
|
||||||
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
|
|
||||||
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
|
|
||||||
|
|
||||||
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
|
|
||||||
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
|
|
||||||
|
|
||||||
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
|
|
||||||
|
|
||||||
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
|
|
||||||
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
|
|
||||||
|
|
||||||
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* `ECONNREFUSED` with Docker ipv6 enabled ([c9a2a46](https://github.com/stonith404/pingvin-share/commit/c9a2a469c67d3c3cd08179b44e2bf82208f05177))
|
|
||||||
|
|
||||||
## [0.17.1](https://github.com/stonith404/pingvin-share/compare/v0.17.0...v0.17.1) (2023-07-30)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* rename pt-PT.ts to pt-BR.ts ([2584bb0](https://github.com/stonith404/pingvin-share/commit/2584bb0d48c761940eafc03d5cd98d47e7a5b0ae))
|
|
||||||
|
|
||||||
## [0.17.0](https://github.com/stonith404/pingvin-share/compare/v0.16.1...v0.17.0) (2023-07-23)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* ability to define zip compression level ([7827b68](https://github.com/stonith404/pingvin-share/commit/7827b687fa022e86a2643e7a1951af8c7e80608c))
|
|
||||||
* add note to language picker ([7f0c31c](https://github.com/stonith404/pingvin-share/commit/7f0c31c2e09b3ee9aae6c3dfb54fac2f2b1dfe23))
|
|
||||||
* add share url alias `/s` ([231a2e9](https://github.com/stonith404/pingvin-share/commit/231a2e95b9734cf4704454e1945698753dbb378b))
|
|
||||||
* localization ([#196](https://github.com/stonith404/pingvin-share/issues/196)) ([b9f6e3b](https://github.com/stonith404/pingvin-share/commit/b9f6e3bd08dcfc050048fba582b35958bc7b6184))
|
|
||||||
* update default value of `maxSize` from `1073741824` to `1000000000` ([389dc87](https://github.com/stonith404/pingvin-share/commit/389dc87cac775d916d0cff9b71d3c5ff90bfe916))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* confusion between GB and GiB ([5816b39](https://github.com/stonith404/pingvin-share/commit/5816b39fc6ef6fe6b7cf8e7925aa297561f5b796))
|
|
||||||
* mistakes in English translations ([70b425b](https://github.com/stonith404/pingvin-share/commit/70b425b3807be79a3b518cc478996c71dffcf986))
|
|
||||||
* wrong layout if button text is too long in modals ([f4c88ae](https://github.com/stonith404/pingvin-share/commit/f4c88aeb0823c2c18535c25fcf8e16afa8b53a56))
|
|
||||||
|
|
||||||
### [0.16.1](https://github.com/stonith404/pingvin-share/compare/v0.16.0...v0.16.1) (2023-07-10)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Adding reverse share ability to copy the link ([#191](https://github.com/stonith404/pingvin-share/issues/191)) ([7574eb3](https://github.com/stonith404/pingvin-share/commit/7574eb3191f21aadd64f436e9e7c78d3e3973a07)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
|
||||||
* Adding reverse shares' shares a clickable link ([#190](https://github.com/stonith404/pingvin-share/issues/190)) ([0276294](https://github.com/stonith404/pingvin-share/commit/0276294f5219a7edcc762bc52391b6720cfd741d))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* set link default value to random ([#192](https://github.com/stonith404/pingvin-share/issues/192)) ([a1ea7c0](https://github.com/stonith404/pingvin-share/commit/a1ea7c026594a54eafd52f764eecbf06e1bb4d4e)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
|
||||||
|
|
||||||
## [0.16.0](https://github.com/stonith404/pingvin-share/compare/v0.15.0...v0.16.0) (2023-07-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Adding more informations on My Shares page (table and modal) ([#174](https://github.com/stonith404/pingvin-share/issues/174)) ([1466240](https://github.com/stonith404/pingvin-share/commit/14662404614f15bc25384d924d8cb0458ab06cd8))
|
|
||||||
* Adding the possibility of copying the link by clicking text and icons ([#171](https://github.com/stonith404/pingvin-share/issues/171)) ([348852c](https://github.com/stonith404/pingvin-share/commit/348852cfa4275f5c642669b43697f83c35333044))
|
|
||||||
|
|
||||||
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
|
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
Caddyfile
15
Caddyfile
@@ -1,15 +0,0 @@
|
|||||||
:3000 {
|
|
||||||
# Reverse proxy for /api
|
|
||||||
reverse_proxy /api/* http://localhost:8080 {
|
|
||||||
header_up X-Forwarded-Host {host}:{server_port}
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto {scheme}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reverse proxy for all other requests
|
|
||||||
reverse_proxy http://localhost:3333 {
|
|
||||||
header_up X-Forwarded-Host {host}:{server_port}
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto {scheme}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
Dockerfile
34
Dockerfile
@@ -1,25 +1,27 @@
|
|||||||
# Stage 1: Frontend dependencies
|
# Using node slim because prisma ORM needs libc for ARM builds
|
||||||
FROM node:20-alpine AS frontend-dependencies
|
|
||||||
|
# Stage 1: on frontend dependency change
|
||||||
|
FROM node:19-slim AS frontend-dependencies
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Stage 2: Build frontend
|
# Stage 2: on frontend change
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:19-slim AS frontend-builder
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY ./frontend .
|
COPY ./frontend .
|
||||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Backend dependencies
|
# Stage 3: on backend dependency change
|
||||||
FROM node:20-alpine AS backend-dependencies
|
FROM node:19-slim AS backend-dependencies
|
||||||
RUN apk add --no-cache python3
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY backend/package.json backend/package-lock.json ./
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Stage 4: Build backend
|
# Stage 4:on backend change
|
||||||
FROM node:20-alpine AS backend-builder
|
FROM node:19-slim AS backend-builder
|
||||||
|
RUN apt-get update && apt-get install -y openssl
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY ./backend .
|
COPY ./backend .
|
||||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||||
@@ -27,12 +29,9 @@ RUN npx prisma generate
|
|||||||
RUN npm run build && npm prune --production
|
RUN npm run build && npm prune --production
|
||||||
|
|
||||||
# Stage 5: Final image
|
# Stage 5: Final image
|
||||||
FROM node:20-alpine AS runner
|
FROM node:19-slim AS runner
|
||||||
ENV NODE_ENV=docker
|
ENV NODE_ENV=docker
|
||||||
|
RUN apt-get update && apt-get install -y curl openssl
|
||||||
RUN apk update --no-cache \
|
|
||||||
&& apk upgrade --no-cache \
|
|
||||||
&& apk add --no-cache curl caddy
|
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
@@ -46,13 +45,8 @@ COPY --from=backend-builder /opt/app/dist ./dist
|
|||||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||||
COPY --from=backend-builder /opt/app/package.json ./
|
COPY --from=backend-builder /opt/app/package.json ./
|
||||||
|
|
||||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
|
||||||
COPY ./scripts/docker-entrypoint.sh /opt/app/docker-entrypoint.sh
|
|
||||||
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
CMD ["sh", "/opt/app/docker-entrypoint.sh"]
|
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod
|
||||||
119
README.md
119
README.md
@@ -1,6 +1,8 @@
|
|||||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
---
|
||||||
|
|
||||||
|
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,13 +26,122 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
## ⌨️ Setup
|
## ⌨️ Setup
|
||||||
|
|
||||||
|
> Note: Pingvin Share is in its early stages and may contain bugs.
|
||||||
|
|
||||||
### Installation with Docker (recommended)
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` file
|
1. Download the `docker-compose.yml` file
|
||||||
2. Run `docker compose up -d`
|
2. Run `docker-compose up -d`
|
||||||
|
|
||||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
## 📚 Documentation
|
### Stand-alone Installation
|
||||||
|
|
||||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
Required tools:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
#### ClamAV (Docker only)
|
||||||
|
|
||||||
|
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||||
|
|
||||||
|
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||||
|
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||||
|
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||||
|
|
||||||
|
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
|
### Additional resources
|
||||||
|
|
||||||
|
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
|
### Upgrade to a new version
|
||||||
|
|
||||||
|
As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stand-alone
|
||||||
|
|
||||||
|
1. Stop the running app
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||||
|
|
||||||
|
#### Environment variables
|
||||||
|
|
||||||
|
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||||
|
|
||||||
|
##### Backend
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||||
|
| `PORT` | `8080` | The port on which the backend listens. |
|
||||||
|
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||||
|
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||||
|
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||||
|
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||||
|
|
||||||
|
##### Frontend
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| --------- | ----------------------- | ---------------------------------------- |
|
||||||
|
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||||
|
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||||
|
|
||||||
|
## 🖤 Contribute
|
||||||
|
|
||||||
|
You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/src/constants.ts
|
|
||||||
11747
backend/package-lock.json
generated
11747
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,87 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --end-of-line=auto --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/cache-manager": "^2.2.2",
|
"@nestjs/common": "^9.3.9",
|
||||||
"@nestjs/common": "^10.3.9",
|
"@nestjs/config": "^2.3.1",
|
||||||
"@nestjs/config": "^3.2.2",
|
"@nestjs/core": "^9.3.9",
|
||||||
"@nestjs/core": "^10.3.9",
|
"@nestjs/jwt": "^10.0.2",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/passport": "^9.0.3",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/platform-express": "^9.3.9",
|
||||||
"@nestjs/platform-express": "^10.3.9",
|
"@nestjs/schedule": "^2.2.0",
|
||||||
"@nestjs/schedule": "^4.0.2",
|
"@nestjs/swagger": "^6.2.1",
|
||||||
"@nestjs/swagger": "^7.3.1",
|
"@nestjs/throttler": "^4.0.0",
|
||||||
"@nestjs/throttler": "^5.2.0",
|
"@prisma/client": "^4.11.0",
|
||||||
"@prisma/client": "^5.16.1",
|
"archiver": "^5.3.1",
|
||||||
"@types/jmespath": "^0.15.2",
|
"argon2": "^0.30.3",
|
||||||
"@types/ldapjs": "^3.0.6",
|
|
||||||
"archiver": "^7.0.1",
|
|
||||||
"argon2": "^0.40.3",
|
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"cache-manager": "^5.6.1",
|
"clamscan": "^2.1.2",
|
||||||
"clamscan": "^2.2.1",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.0",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"jmespath": "^0.16.0",
|
|
||||||
"ldapjs": "^3.0.7",
|
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.29.4",
|
||||||
"nanoid": "^3.3.7",
|
"nodemailer": "^6.9.1",
|
||||||
"nodemailer": "^6.9.14",
|
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^5.0.7",
|
"rimraf": "^4.4.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.0",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.31.3",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.2",
|
"@nestjs/cli": "^9.2.0",
|
||||||
"@nestjs/schematics": "^10.1.1",
|
"@nestjs/schematics": "^9.0.4",
|
||||||
"@nestjs/testing": "^10.3.9",
|
"@nestjs/testing": "^9.3.9",
|
||||||
"@types/archiver": "^6.0.2",
|
"@types/archiver": "^5.3.1",
|
||||||
"@types/clamscan": "^2.0.8",
|
"@types/clamscan": "^2.0.4",
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.1",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.17",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^18.15.0",
|
||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.7",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^3.0.8",
|
||||||
"@types/qrcode-svg": "^1.1.4",
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
||||||
"@typescript-eslint/parser": "^7.14.1",
|
"@typescript-eslint/parser": "^5.54.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.35.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^8.7.0",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"newman": "^6.1.3",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^2.8.4",
|
||||||
"prisma": "^5.16.1",
|
"prisma": "^4.11.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.4.2",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^4.9.5",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `description` on the `Config` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_Config" (
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"category" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"defaultValue" TEXT NOT NULL DEFAULT '',
|
|
||||||
"value" TEXT,
|
|
||||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"order" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY ("name", "category")
|
|
||||||
);
|
|
||||||
INSERT INTO "new_Config" ("category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
|
||||||
DROP TABLE "Config";
|
|
||||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
|
||||||
PRAGMA foreign_key_check;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "OAuthUser" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"providerUserId" TEXT NOT NULL,
|
|
||||||
"providerUsername" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_User" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
"username" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"password" TEXT,
|
|
||||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"totpSecret" TEXT
|
|
||||||
);
|
|
||||||
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User";
|
|
||||||
DROP TABLE "User";
|
|
||||||
ALTER TABLE "new_User" RENAME TO "User";
|
|
||||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
PRAGMA foreign_key_check;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Share" ADD COLUMN "name" TEXT;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- RedefineTables
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_ReverseShare" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"shareExpiration" DATETIME NOT NULL,
|
|
||||||
"maxShareSize" TEXT NOT NULL,
|
|
||||||
"sendEmailNotification" BOOLEAN NOT NULL,
|
|
||||||
"remainingUses" INTEGER NOT NULL,
|
|
||||||
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"creatorId" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token" FROM "ReverseShare";
|
|
||||||
DROP TABLE "ReverseShare";
|
|
||||||
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
|
||||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
|
||||||
PRAGMA foreign_key_check;
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_ReverseShare" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"shareExpiration" DATETIME NOT NULL,
|
|
||||||
"maxShareSize" TEXT NOT NULL,
|
|
||||||
"sendEmailNotification" BOOLEAN NOT NULL,
|
|
||||||
"remainingUses" INTEGER NOT NULL,
|
|
||||||
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"publicAccess" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"creatorId" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token" FROM "ReverseShare";
|
|
||||||
DROP TABLE "ReverseShare";
|
|
||||||
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
|
||||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[ldapDN]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "ldapDN" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_ldapDN_key" ON "User"("ldapDN");
|
|
||||||
@@ -14,9 +14,8 @@ model User {
|
|||||||
|
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
password String?
|
password String
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
ldapDN String? @unique
|
|
||||||
|
|
||||||
shares Share[]
|
shares Share[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
@@ -27,8 +26,6 @@ model User {
|
|||||||
totpVerified Boolean @default(false)
|
totpVerified Boolean @default(false)
|
||||||
totpSecret String?
|
totpSecret String?
|
||||||
resetPasswordToken ResetPasswordToken?
|
resetPasswordToken ResetPasswordToken?
|
||||||
|
|
||||||
oAuthUsers OAuthUser[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@@ -63,20 +60,10 @@ model ResetPasswordToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model OAuthUser {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
provider String
|
|
||||||
providerUserId String
|
|
||||||
providerUsername String
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Share {
|
model Share {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
name String?
|
|
||||||
uploadLocked Boolean @default(false)
|
uploadLocked Boolean @default(false)
|
||||||
isZipReady Boolean @default(false)
|
isZipReady Boolean @default(false)
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
@@ -104,8 +91,6 @@ model ReverseShare {
|
|||||||
maxShareSize String
|
maxShareSize String
|
||||||
sendEmailNotification Boolean
|
sendEmailNotification Boolean
|
||||||
remainingUses Int
|
remainingUses Int
|
||||||
simplified Boolean @default(false)
|
|
||||||
publicAccess Boolean @default(true)
|
|
||||||
|
|
||||||
creatorId String
|
creatorId String
|
||||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
@@ -151,6 +136,7 @@ model Config {
|
|||||||
type String
|
type String
|
||||||
defaultValue String @default("")
|
defaultValue String @default("")
|
||||||
value String?
|
value String?
|
||||||
|
description String
|
||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
|
|||||||
@@ -1,110 +1,115 @@
|
|||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
const configVariables: ConfigVariables = {
|
const configVariables: ConfigVariables = {
|
||||||
internal: {
|
internal: {
|
||||||
jwtSecret: {
|
jwtSecret: {
|
||||||
|
description: "Long random string used to sign JWT tokens",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: crypto.randomBytes(256).toString("base64"),
|
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
appName: {
|
appName: {
|
||||||
|
description: "Name of the application",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "Pingvin Share",
|
defaultValue: "Pingvin Share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
appUrl: {
|
appUrl: {
|
||||||
|
description: "On which URL Pingvin Share is available",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "http://localhost:3000",
|
defaultValue: "http://localhost:3000",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
showHomePage: {
|
showHomePage: {
|
||||||
|
description: "Whether to show the home page",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "true",
|
defaultValue: "true",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
sessionDuration: {
|
|
||||||
type: "number",
|
|
||||||
defaultValue: "2160",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
share: {
|
share: {
|
||||||
allowRegistration: {
|
allowRegistration: {
|
||||||
|
description: "Whether registration is allowed",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "true",
|
defaultValue: "true",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
allowUnauthenticatedShares: {
|
allowUnauthenticatedShares: {
|
||||||
|
description: "Whether unauthorized users can create shares",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
maxExpiration: {
|
|
||||||
type: "number",
|
|
||||||
defaultValue: "0",
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
maxSize: {
|
maxSize: {
|
||||||
|
description: "Maximum share size in bytes",
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: "1000000000",
|
defaultValue: "1073741824",
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
zipCompressionLevel: {
|
|
||||||
type: "number",
|
|
||||||
defaultValue: "9",
|
|
||||||
},
|
|
||||||
chunkSize: {
|
|
||||||
type: "number",
|
|
||||||
defaultValue: "10000000",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
autoOpenShareModal: {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
enableShareEmailRecipients: {
|
enableShareEmailRecipients: {
|
||||||
|
description:
|
||||||
|
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
shareRecipientsSubject: {
|
shareRecipientsSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent to the share recipients.",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "Files shared with you",
|
defaultValue: "Files shared with you",
|
||||||
},
|
},
|
||||||
shareRecipientsMessage: {
|
shareRecipientsMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent to the share recipients.\n\nAvailable variables:\n{creator} - The username of the creator of the share\n{shareUrl} - The URL of the share\n{desc} - The description of the share\n{expires} - The expiration date of the share\n\nVariables will be replaced with the actual values.",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||||
},
|
},
|
||||||
reverseShareSubject: {
|
reverseShareSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "Reverse share link used",
|
defaultValue: "Reverse share link used",
|
||||||
},
|
},
|
||||||
reverseShareMessage: {
|
reverseShareMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||||
},
|
},
|
||||||
resetPasswordSubject: {
|
resetPasswordSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when a user requests a password reset.",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "Pingvin Share password reset",
|
defaultValue: "Pingvin Share password reset",
|
||||||
},
|
},
|
||||||
resetPasswordMessage: {
|
resetPasswordMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||||
},
|
},
|
||||||
inviteSubject: {
|
inviteSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when an admin invites an user.",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "Pingvin Share invite",
|
defaultValue: "Pingvin Share invite",
|
||||||
},
|
},
|
||||||
inviteMessage: {
|
inviteMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
||||||
@@ -112,177 +117,34 @@ const configVariables: ConfigVariables = {
|
|||||||
},
|
},
|
||||||
smtp: {
|
smtp: {
|
||||||
enabled: {
|
enabled: {
|
||||||
|
description:
|
||||||
|
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
allowUnauthorizedCertificates: {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
host: {
|
host: {
|
||||||
|
description: "Host of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
|
description: "Port of the SMTP server",
|
||||||
type: "number",
|
type: "number",
|
||||||
defaultValue: "0",
|
defaultValue: "0",
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
|
description: "Email address which the emails get sent from",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
username: {
|
username: {
|
||||||
|
description: "Username of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
type: "string",
|
description: "Password of the SMTP server",
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ldap: {
|
|
||||||
enabled: {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
url: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
|
|
||||||
bindDn: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
bindPassword: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
searchBase: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
searchQuery: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: ""
|
|
||||||
},
|
|
||||||
|
|
||||||
adminGroups: {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
"allowRegistration": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "true",
|
|
||||||
},
|
|
||||||
"ignoreTotp": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "true",
|
|
||||||
},
|
|
||||||
"disablePassword": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
"github-enabled": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
},
|
|
||||||
"github-clientId": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"github-clientSecret": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
"google-enabled": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
},
|
|
||||||
"google-clientId": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"google-clientSecret": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
"microsoft-enabled": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
},
|
|
||||||
"microsoft-tenant": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "common",
|
|
||||||
},
|
|
||||||
"microsoft-clientId": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"microsoft-clientSecret": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
"discord-enabled": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
},
|
|
||||||
"discord-limitedGuild": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"discord-clientId": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"discord-clientSecret": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
obscured: true,
|
|
||||||
},
|
|
||||||
"oidc-enabled": {
|
|
||||||
type: "boolean",
|
|
||||||
defaultValue: "false",
|
|
||||||
},
|
|
||||||
"oidc-discoveryUri": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-usernameClaim": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-rolePath": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-roleGeneralAccess": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-roleAdminAccess": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-clientId": {
|
|
||||||
type: "string",
|
|
||||||
defaultValue: "",
|
|
||||||
},
|
|
||||||
"oidc-clientSecret": {
|
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
@@ -339,15 +201,12 @@ async function seedConfigVariables() {
|
|||||||
|
|
||||||
async function migrateConfigVariables() {
|
async function migrateConfigVariables() {
|
||||||
const existingConfigVariables = await prisma.config.findMany();
|
const existingConfigVariables = await prisma.config.findMany();
|
||||||
const orderMap: { [category: string]: number } = {};
|
|
||||||
|
|
||||||
for (const existingConfigVariable of existingConfigVariables) {
|
for (const existingConfigVariable of existingConfigVariables) {
|
||||||
const configVariable =
|
const configVariable =
|
||||||
configVariables[existingConfigVariable.category]?.[
|
configVariables[existingConfigVariable.category]?.[
|
||||||
existingConfigVariable.name
|
existingConfigVariable.name
|
||||||
];
|
];
|
||||||
|
|
||||||
// Delete the config variable if it doesn't exist in the seed
|
|
||||||
if (!configVariable) {
|
if (!configVariable) {
|
||||||
await prisma.config.delete({
|
await prisma.config.delete({
|
||||||
where: {
|
where: {
|
||||||
@@ -358,11 +217,15 @@ async function migrateConfigVariables() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the config variable if it exists in the seed
|
// Update the config variable if the metadata changed
|
||||||
} else {
|
} else if (
|
||||||
const variableOrder = Object.keys(
|
JSON.stringify({
|
||||||
configVariables[existingConfigVariable.category]
|
...configVariable,
|
||||||
).indexOf(existingConfigVariable.name);
|
name: existingConfigVariable.name,
|
||||||
|
category: existingConfigVariable.category,
|
||||||
|
value: existingConfigVariable.value,
|
||||||
|
}) != JSON.stringify(existingConfigVariable)
|
||||||
|
) {
|
||||||
await prisma.config.update({
|
await prisma.config.update({
|
||||||
where: {
|
where: {
|
||||||
name_category: {
|
name_category: {
|
||||||
@@ -375,10 +238,8 @@ async function migrateConfigVariables() {
|
|||||||
name: existingConfigVariable.name,
|
name: existingConfigVariable.name,
|
||||||
category: existingConfigVariable.category,
|
category: existingConfigVariable.category,
|
||||||
value: existingConfigVariable.value,
|
value: existingConfigVariable.value,
|
||||||
order: variableOrder,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
orderMap[existingConfigVariable.category] = variableOrder + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Controller, Get, Res } from "@nestjs/common";
|
|
||||||
import { Response } from "express";
|
|
||||||
import { PrismaService } from "./prisma/prisma.service";
|
|
||||||
|
|
||||||
@Controller("/")
|
|
||||||
export class AppController {
|
|
||||||
constructor(private prismaService: PrismaService) {}
|
|
||||||
|
|
||||||
@Get("health")
|
|
||||||
async health(@Res({ passthrough: true }) res: Response) {
|
|
||||||
try {
|
|
||||||
await this.prismaService.config.findMany();
|
|
||||||
return "OK";
|
|
||||||
} catch {
|
|
||||||
res.statusCode = 500;
|
|
||||||
return "ERROR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,20 +3,17 @@ import { Module } from "@nestjs/common";
|
|||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
|
||||||
import { CacheModule } from "@nestjs/cache-manager";
|
|
||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { AppController } from "./app.controller";
|
|
||||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
|
||||||
import { ConfigModule } from "./config/config.module";
|
import { ConfigModule } from "./config/config.module";
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { FileModule } from "./file/file.module";
|
import { FileModule } from "./file/file.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { OAuthModule } from "./oauth/oauth.module";
|
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
|
||||||
import { ShareModule } from "./share/share.module";
|
import { ShareModule } from "./share/share.module";
|
||||||
import { UserModule } from "./user/user.module";
|
import { UserModule } from "./user/user.module";
|
||||||
|
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||||
|
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,21 +25,14 @@ import { UserModule } from "./user/user.module";
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
ThrottlerModule.forRoot([
|
ThrottlerModule.forRoot({
|
||||||
{
|
|
||||||
ttl: 60,
|
ttl: 60,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
}),
|
||||||
]),
|
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ClamScanModule,
|
ClamScanModule,
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
OAuthModule,
|
|
||||||
CacheModule.register({
|
|
||||||
isGlobal: true,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
|
|||||||
@@ -33,55 +33,43 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private authTotpService: AuthTotpService,
|
private authTotpService: AuthTotpService,
|
||||||
private config: ConfigService,
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
@Throttle({
|
@Throttle(10, 5 * 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
async signUp(
|
async signUp(
|
||||||
@Body() dto: AuthRegisterDTO,
|
@Body() dto: AuthRegisterDTO,
|
||||||
@Req() { ip }: Request,
|
@Res({ passthrough: true }) response: Response
|
||||||
@Res({ passthrough: true }) response: Response,
|
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("share.allowRegistration"))
|
if (!this.config.get("share.allowRegistration"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto, ip);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
this.authService.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
@Throttle({
|
@Throttle(10, 5 * 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signIn(
|
async signIn(
|
||||||
@Body() dto: AuthSignInDTO,
|
@Body() dto: AuthSignInDTO,
|
||||||
@Req() { ip }: Request,
|
@Res({ passthrough: true }) response: Response
|
||||||
@Res({ passthrough: true }) response: Response,
|
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.signIn(dto, ip);
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
if (result.accessToken && result.refreshToken) {
|
if (result.accessToken && result.refreshToken) {
|
||||||
this.authService.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,47 +77,32 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
@Throttle({
|
@Throttle(10, 5 * 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signInTotp(
|
async signInTotp(
|
||||||
@Body() dto: AuthSignInTotpDTO,
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response
|
||||||
) {
|
) {
|
||||||
const result = await this.authTotpService.signInTotp(dto);
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
this.authService.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("resetPassword/:email")
|
@Post("resetPassword/:email")
|
||||||
@Throttle({
|
@Throttle(5, 5 * 60)
|
||||||
default: {
|
@HttpCode(204)
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@HttpCode(202)
|
|
||||||
async requestResetPassword(@Param("email") email: string) {
|
async requestResetPassword(@Param("email") email: string) {
|
||||||
this.authService.requestResetPassword(email);
|
return await this.authService.requestResetPassword(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("resetPassword")
|
@Post("resetPassword")
|
||||||
@Throttle({
|
@Throttle(5, 5 * 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||||
return await this.authService.resetPassword(dto.token, dto.password);
|
return await this.authService.resetPassword(dto.token, dto.password);
|
||||||
@@ -140,15 +113,15 @@ export class AuthController {
|
|||||||
async updatePassword(
|
async updatePassword(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() dto: UpdatePasswordDTO,
|
@Body() dto: UpdatePasswordDTO
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.updatePassword(
|
const result = await this.authService.updatePassword(
|
||||||
user,
|
user,
|
||||||
dto.password,
|
|
||||||
dto.oldPassword,
|
dto.oldPassword,
|
||||||
|
dto.password
|
||||||
);
|
);
|
||||||
|
|
||||||
this.authService.addTokensToResponse(response, result.refreshToken);
|
response = this.addTokensToResponse(response, result.refreshToken);
|
||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,21 +129,21 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response
|
||||||
) {
|
) {
|
||||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
request.cookies.refresh_token,
|
request.cookies.refresh_token
|
||||||
);
|
);
|
||||||
this.authService.addTokensToResponse(response, undefined, accessToken);
|
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||||
return new TokenDTO().from({ accessToken });
|
return new TokenDTO().from({ accessToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("signOut")
|
@Post("signOut")
|
||||||
async signOut(
|
async signOut(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response
|
||||||
) {
|
) {
|
||||||
await this.authService.signOut(request.cookies.access_token);
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
@@ -199,4 +172,22 @@ export class AuthController {
|
|||||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||||
return this.authTotpService.disableTotp(user, body.password, body.code);
|
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addTokensToResponse(
|
||||||
|
response: Response,
|
||||||
|
refreshToken?: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
if (accessToken)
|
||||||
|
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||||
|
if (refreshToken)
|
||||||
|
response.cookie("refresh_token", refreshToken, {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,11 @@ import { AuthController } from "./auth.controller";
|
|||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthTotpService } from "./authTotp.service";
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
import { LdapService } from "./ldap.service";
|
|
||||||
import { UserModule } from "../user/user.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [JwtModule.register({}), EmailModule],
|
||||||
JwtModule.register({
|
|
||||||
global: true,
|
|
||||||
}),
|
|
||||||
EmailModule,
|
|
||||||
UserModule,
|
|
||||||
],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule { }
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -2,23 +2,18 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import { Request, Response } from "express";
|
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { LdapService } from "./ldap.service";
|
|
||||||
import { inspect } from "util";
|
|
||||||
import { UserSevice } from "../user/user.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -26,92 +21,64 @@ export class AuthService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService
|
||||||
private ldapService: LdapService,
|
) {}
|
||||||
private userService: UserSevice,
|
|
||||||
) { }
|
|
||||||
private readonly logger = new Logger(AuthService.name);
|
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||||
|
|
||||||
const hash = dto.password ? await argon.hash(dto.password) : null;
|
const hash = await argon.hash(dto.password);
|
||||||
try {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password: hash,
|
password: hash,
|
||||||
isAdmin: isAdmin ?? isFirstUser,
|
isAdmin: isFirstUser,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id,
|
user.id
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
this.logger.log(`User ${user.email} signed up from IP ${ip}`);
|
return { accessToken, refreshToken };
|
||||||
return { accessToken, refreshToken, user };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PrismaClientKnownRequestError) {
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`,
|
`A user with this ${duplicatedField} already exists`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
async signIn(dto: AuthSignInDTO) {
|
||||||
if (!dto.email && !dto.username)
|
if (!dto.email && !dto.username)
|
||||||
throw new BadRequestException("Email or username is required");
|
throw new BadRequestException("Email or username is required");
|
||||||
|
|
||||||
if (!this.config.get("oauth.disablePassword")) {
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ email: dto.email }, { username: dto.username }],
|
OR: [{ email: dto.email }, { username: dto.username }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user?.password && await argon.verify(user.password, dto.password)) {
|
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||||
this.logger.log(`Successful password login for user ${user.email} from IP ${ip}`);
|
|
||||||
return this.generateToken(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.get("ldap.enabled")) {
|
|
||||||
this.logger.debug(`Trying LDAP login for user ${dto.username}`);
|
|
||||||
const ldapUser = await this.ldapService.authenticateUser(dto.username, dto.password);
|
|
||||||
if (ldapUser) {
|
|
||||||
const user = await this.userService.findOrCreateFromLDAP(dto.username, ldapUser);
|
|
||||||
this.logger.log(`Successful LDAP login for user ${user.email} from IP ${ip}`);
|
|
||||||
return this.generateToken(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Failed login attempt for user ${dto.email || dto.username} from IP ${ip}`,
|
|
||||||
);
|
|
||||||
throw new UnauthorizedException("Wrong email or password");
|
throw new UnauthorizedException("Wrong email or password");
|
||||||
}
|
|
||||||
|
|
||||||
async generateToken(user: User, isOAuth = false) {
|
|
||||||
// TODO: Make all old loginTokens invalid when a new one is created
|
// TODO: Make all old loginTokens invalid when a new one is created
|
||||||
// Check if the user has TOTP enabled
|
// Check if the user has TOTP enabled
|
||||||
if (
|
if (user.totpVerified) {
|
||||||
user.totpVerified &&
|
|
||||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
|
||||||
) {
|
|
||||||
const loginToken = await this.createLoginToken(user.id);
|
const loginToken = await this.createLoginToken(user.id);
|
||||||
|
|
||||||
return { loginToken };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id,
|
user.id
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
@@ -119,15 +86,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestResetPassword(email: string) {
|
async requestResetPassword(email: string) {
|
||||||
if (this.config.get("oauth.disablePassword"))
|
|
||||||
throw new ForbiddenException("Password sign in is disabled");
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { email },
|
where: { email },
|
||||||
include: { resetPasswordToken: true },
|
include: { resetPasswordToken: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return;
|
if (!user) throw new BadRequestException("User not found");
|
||||||
|
|
||||||
// Delete old reset password token
|
// Delete old reset password token
|
||||||
if (user.resetPasswordToken) {
|
if (user.resetPasswordToken) {
|
||||||
@@ -147,9 +111,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
if (this.config.get("oauth.disablePassword"))
|
|
||||||
throw new ForbiddenException("Password sign in is disabled");
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { resetPasswordToken: { token } },
|
where: { resetPasswordToken: { token } },
|
||||||
});
|
});
|
||||||
@@ -168,11 +129,9 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
|
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||||
const isPasswordValid =
|
if (!(await argon.verify(user.password, oldPassword)))
|
||||||
!user.password || (await argon.verify(user.password, oldPassword));
|
throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
|
|
||||||
|
|
||||||
const hash = await argon.hash(newPassword);
|
const hash = await argon.hash(newPassword);
|
||||||
|
|
||||||
@@ -199,7 +158,7 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,18 +189,13 @@ export class AuthService {
|
|||||||
|
|
||||||
return this.createAccessToken(
|
return this.createAccessToken(
|
||||||
refreshTokenMetaData.user,
|
refreshTokenMetaData.user,
|
||||||
refreshTokenMetaData.id,
|
refreshTokenMetaData.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string) {
|
||||||
const { id, token } = await this.prisma.refreshToken.create({
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
data: {
|
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
||||||
userId,
|
|
||||||
expiresAt: moment()
|
|
||||||
.add(this.config.get("general.sessionDuration"), "hours")
|
|
||||||
.toDate(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { refreshTokenId: id, refreshToken: token };
|
return { refreshTokenId: id, refreshToken: token };
|
||||||
@@ -256,41 +210,4 @@ export class AuthService {
|
|||||||
|
|
||||||
return loginToken;
|
return loginToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTokensToResponse(
|
|
||||||
response: Response,
|
|
||||||
refreshToken?: string,
|
|
||||||
accessToken?: string,
|
|
||||||
) {
|
|
||||||
if (accessToken)
|
|
||||||
response.cookie("access_token", accessToken, {
|
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
|
||||||
});
|
|
||||||
if (refreshToken)
|
|
||||||
response.cookie("refresh_token", refreshToken, {
|
|
||||||
path: "/api/auth/token",
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the user id if the user is logged in, null otherwise
|
|
||||||
*/
|
|
||||||
async getIdOfCurrentUser(request: Request): Promise<string | null> {
|
|
||||||
if (!request.cookies.access_token) return null;
|
|
||||||
try {
|
|
||||||
const payload = await this.jwtService.verifyAsync(
|
|
||||||
request.cookies.access_token,
|
|
||||||
{
|
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return payload.sub;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
|
|||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import { authenticator, totp } from "otplib";
|
import { authenticator, totp } from "otplib";
|
||||||
import * as qrcode from "qrcode-svg";
|
import * as qrcode from "qrcode-svg";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
@@ -17,32 +18,47 @@ export class AuthTotpService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||||
|
if (!dto.email && !dto.username)
|
||||||
|
throw new BadRequestException("Email or username is required");
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email: dto.email }, { username: dto.username }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||||
|
throw new UnauthorizedException("Wrong email or password");
|
||||||
|
|
||||||
const token = await this.prisma.loginToken.findFirst({
|
const token = await this.prisma.loginToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token: dto.loginToken,
|
token: dto.loginToken,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!token || token.used)
|
if (!token || token.userId != user.id || token.used)
|
||||||
throw new UnauthorizedException("Invalid login token");
|
throw new UnauthorizedException("Invalid login token");
|
||||||
|
|
||||||
if (token.expiresAt < new Date())
|
if (token.expiresAt < new Date())
|
||||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||||
|
|
||||||
// Check the TOTP code
|
// Check the TOTP code
|
||||||
const { totpSecret } = token.user;
|
const { totpSecret } = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpSecret: true },
|
||||||
|
});
|
||||||
|
|
||||||
if (!totpSecret) {
|
if (!totpSecret) {
|
||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticator.check(dto.totp, totpSecret)) {
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
|
if (dto.totp !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +69,10 @@ export class AuthTotpService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } =
|
const { refreshToken, refreshTokenId } =
|
||||||
await this.authService.createRefreshToken(token.user.id);
|
await this.authService.createRefreshToken(user.id);
|
||||||
const accessToken = await this.authService.createAccessToken(
|
const accessToken = await this.authService.createAccessToken(
|
||||||
token.user,
|
user,
|
||||||
refreshTokenId,
|
refreshTokenId
|
||||||
);
|
);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
@@ -76,12 +92,13 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is already enabled");
|
throw new BadRequestException("TOTP is already enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Maybe make the issuer configurable with env vars?
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
|
|
||||||
const otpURL = totp.keyuri(
|
const otpURL = totp.keyuri(
|
||||||
user.username || user.email,
|
user.username || user.email,
|
||||||
"pingvin-share",
|
this.config.get("general.appName"),
|
||||||
secret,
|
secret
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
|
|||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||||
|
|
||||||
export class AuthSignInTotpDTO {
|
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
totp: string;
|
totp: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { PickType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsOptional, IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
oldPassword: string;
|
||||||
oldPassword?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
|
||||||
import * as ldap from "ldapjs";
|
|
||||||
import { AttributeJson, InvalidCredentialsError, SearchCallbackResponse, SearchOptions } from "ldapjs";
|
|
||||||
import { inspect } from "node:util";
|
|
||||||
import { ConfigService } from "../config/config.service";
|
|
||||||
|
|
||||||
type LdapSearchEntry = {
|
|
||||||
objectName: string,
|
|
||||||
attributes: AttributeJson[],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ldapExecuteSearch(client: ldap.Client, base: string, options: SearchOptions): Promise<LdapSearchEntry[]> {
|
|
||||||
const searchResponse = await new Promise<SearchCallbackResponse>((resolve, reject) => {
|
|
||||||
client.search(base, options, (err, res) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return await new Promise<any[]>((resolve, reject) => {
|
|
||||||
const entries: LdapSearchEntry[] = [];
|
|
||||||
searchResponse.on("searchEntry", entry => entries.push({ attributes: entry.pojo.attributes, objectName: entry.pojo.objectName }));
|
|
||||||
searchResponse.once("error", reject);
|
|
||||||
searchResponse.once("end", () => resolve(entries));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ldapBindUser(client: ldap.Client, dn: string, password: string): Promise<void> {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
client.bind(dn, password, error => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ldapCreateConnection(logger: Logger, url: string): Promise<ldap.Client> {
|
|
||||||
const ldapClient = ldap.createClient({
|
|
||||||
url: url.split(","),
|
|
||||||
connectTimeout: 10_000,
|
|
||||||
timeout: 10_000
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
ldapClient.once("error", reject);
|
|
||||||
ldapClient.on("setupError", reject);
|
|
||||||
ldapClient.on("socketTimeout", reject);
|
|
||||||
ldapClient.on("connectRefused", () => reject(new Error("connection has been refused")));
|
|
||||||
ldapClient.on("connectTimeout", () => reject(new Error("connect timed out")));
|
|
||||||
ldapClient.on("connectError", reject);
|
|
||||||
|
|
||||||
ldapClient.on("connect", resolve);
|
|
||||||
}).catch(error => {
|
|
||||||
logger.error(`Connect error: ${inspect(error)}`);
|
|
||||||
ldapClient.destroy();
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
|
|
||||||
return ldapClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LdapAuthenticateResult = {
|
|
||||||
userDn: string,
|
|
||||||
attributes: Record<string, string[]>
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LdapService {
|
|
||||||
private readonly logger = new Logger(LdapService.name);
|
|
||||||
constructor(
|
|
||||||
@Inject(ConfigService)
|
|
||||||
private readonly serviceConfig: ConfigService,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
private async createLdapConnection(): Promise<ldap.Client> {
|
|
||||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
|
||||||
if (!ldapUrl) {
|
|
||||||
throw new Error("LDAP server URL is not defined");
|
|
||||||
}
|
|
||||||
|
|
||||||
const ldapClient = await ldapCreateConnection(this.logger, ldapUrl);
|
|
||||||
try {
|
|
||||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
|
||||||
if (bindDn) {
|
|
||||||
try {
|
|
||||||
await ldapBindUser(ldapClient, bindDn, this.serviceConfig.get("ldap.bindPassword"))
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to bind to default user: ${error}`);
|
|
||||||
throw new Error("failed to bind to default user");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ldapClient;
|
|
||||||
} catch (error) {
|
|
||||||
ldapClient.destroy();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async authenticateUser(username: string, password: string): Promise<LdapAuthenticateResult | null> {
|
|
||||||
if (!username.match(/^[a-zA-Z0-0]+$/)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchBase = this.serviceConfig.get("ldap.searchBase");
|
|
||||||
const searchQuery = this.serviceConfig.get("ldap.searchQuery")
|
|
||||||
.replaceAll("%username%", username);
|
|
||||||
|
|
||||||
const ldapClient = await this.createLdapConnection();
|
|
||||||
try {
|
|
||||||
const [result] = await ldapExecuteSearch(ldapClient, searchBase, {
|
|
||||||
filter: searchQuery,
|
|
||||||
scope: "sub"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
/* user not found */
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ldapBindUser(ldapClient, result.objectName, password);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* In theory we could query the user attributes now,
|
|
||||||
* but as we must query the user attributes for validation anyways
|
|
||||||
* we'll create a second ldap server connection.
|
|
||||||
*/
|
|
||||||
return {
|
|
||||||
userDn: result.objectName,
|
|
||||||
attributes: Object.fromEntries(result.attributes.map(attribute => [attribute.type, attribute.values])),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof InvalidCredentialsError) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.warn(`LDAP user bind failure: ${inspect(error)}`);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
ldapClient.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`LDAP connect error: ${inspect(error)}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,7 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||||
config: ConfigService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {
|
|
||||||
config.get("internal.jwtSecret");
|
config.get("internal.jwtSecret");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: JwtStrategy.extractJWT,
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class ClamScanService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||||
@@ -81,7 +81,7 @@ export class ClamScanService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class ConfigController {
|
|||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private logoService: LogoService,
|
private logoService: LogoService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -41,7 +41,7 @@ export class ConfigController {
|
|||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async getByCategory(@Param("category") category: string) {
|
async getByCategory(@Param("category") category: string) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.getByCategory(category),
|
await this.configService.getByCategory(category)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export class ConfigController {
|
|||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.updateMany(data),
|
await this.configService.updateMany(data)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +66,9 @@ export class ConfigController {
|
|||||||
@UploadedFile(
|
@UploadedFile(
|
||||||
new ParseFilePipe({
|
new ParseFilePipe({
|
||||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File
|
||||||
) {
|
) {
|
||||||
return await this.logoService.create(file.buffer);
|
return await this.logoService.create(file.buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,17 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Config } from "@prisma/client";
|
import { Config } from "@prisma/client";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { EventEmitter } from "events";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConfigService extends EventEmitter to allow listening for config updates,
|
|
||||||
* now only `update` event will be emitted.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConfigService extends EventEmitter {
|
export class ConfigService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService
|
||||||
) {
|
) {}
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: `${string}.${string}`): any {
|
get(key: `${string}.${string}`): any {
|
||||||
const configVariable = this.configVariables.filter(
|
const configVariable = this.configVariables.filter(
|
||||||
(variable) => `${variable.category}.${variable.name}` == key,
|
(variable) => `${variable.category}.${variable.name}` == key
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||||
@@ -96,7 +89,7 @@ export class ConfigService extends EventEmitter {
|
|||||||
configVariable.type != "text"
|
configVariable.type != "text"
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Config variable must be of type ${configVariable.type}`,
|
`Config variable must be of type ${configVariable.type}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +105,6 @@ export class ConfigService extends EventEmitter {
|
|||||||
|
|
||||||
this.configVariables = await this.prisma.config.findMany();
|
this.configVariables = await this.prisma.config.findMany();
|
||||||
|
|
||||||
this.emit("update", key, value);
|
|
||||||
|
|
||||||
return updatedVariable;
|
return updatedVariable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
description: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
|
|
||||||
@@ -25,7 +28,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
|
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class ConfigDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<ConfigDTO>[]) {
|
fromList(partial: Partial<ConfigDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }),
|
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ const IMAGES_PATH = "../frontend/public/img";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogoService {
|
export class LogoService {
|
||||||
async create(file: Buffer) {
|
async create(file: Buffer) {
|
||||||
const resized = await sharp(file).resize(900).toBuffer();
|
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
|
||||||
this.createFavicon(file);
|
this.createFavicon(file);
|
||||||
this.createPWAIcons(file);
|
this.createPWAIcons(file);
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,7 @@ export class LogoService {
|
|||||||
fs.promises.writeFile(
|
fs.promises.writeFile(
|
||||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||||
resized,
|
resized,
|
||||||
"binary",
|
"binary"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`
|
||||||
export const DATABASE_URL =
|
export const DATABASE_URL = process.env.DATABASE_URL || "file:../data/pingvin-share.db?connection_limit=1";
|
||||||
process.env.DATABASE_URL ||
|
export const CLAMAV_HOST = process.env.CLAMAV_HOST || (process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||||
"file:../data/pingvin-share.db?connection_limit=1";
|
|
||||||
export const CLAMAV_HOST =
|
|
||||||
process.env.CLAMAV_HOST ||
|
|
||||||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
|
||||||
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||||
@@ -25,11 +25,6 @@ export class EmailService {
|
|||||||
user: this.config.get("smtp.username"),
|
user: this.config.get("smtp.username"),
|
||||||
pass: this.config.get("smtp.password"),
|
pass: this.config.get("smtp.password"),
|
||||||
},
|
},
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: !this.config.get(
|
|
||||||
"smtp.allowUnauthorizedCertificates",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +32,7 @@ export class EmailService {
|
|||||||
await this.getTransporter()
|
await this.getTransporter()
|
||||||
.sendMail({
|
.sendMail({
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
"smtp.email",
|
"smtp.email"
|
||||||
)}>`,
|
)}>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -54,12 +49,12 @@ export class EmailService {
|
|||||||
shareId: string,
|
shareId: string,
|
||||||
creator?: User,
|
creator?: User,
|
||||||
description?: string,
|
description?: string,
|
||||||
expiration?: Date,
|
expiration?: Date
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||||
|
|
||||||
await this.sendMail(
|
await this.sendMail(
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
@@ -74,13 +69,13 @@ export class EmailService {
|
|||||||
"{expires}",
|
"{expires}",
|
||||||
moment(expiration).unix() != 0
|
moment(expiration).unix() != 0
|
||||||
? moment(expiration).fromNow()
|
? moment(expiration).fromNow()
|
||||||
: "in: never",
|
: "in: never"
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
||||||
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||||
|
|
||||||
await this.sendMail(
|
await this.sendMail(
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
@@ -88,13 +83,13 @@ export class EmailService {
|
|||||||
this.config
|
this.config
|
||||||
.get("email.reverseShareMessage")
|
.get("email.reverseShareMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||||
const resetPasswordUrl = `${this.config.get(
|
const resetPasswordUrl = `${this.config.get(
|
||||||
"general.appUrl",
|
"general.appUrl"
|
||||||
)}/auth/resetPassword/${token}`;
|
)}/auth/resetPassword/${token}`;
|
||||||
|
|
||||||
await this.sendMail(
|
await this.sendMail(
|
||||||
@@ -103,7 +98,7 @@ export class EmailService {
|
|||||||
this.config
|
this.config
|
||||||
.get("email.resetPasswordMessage")
|
.get("email.resetPasswordMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{url}", resetPasswordUrl),
|
.replaceAll("{url}", resetPasswordUrl)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +111,7 @@ export class EmailService {
|
|||||||
this.config
|
this.config
|
||||||
.get("email.inviteMessage")
|
.get("email.inviteMessage")
|
||||||
.replaceAll("{url}", loginUrl)
|
.replaceAll("{url}", loginUrl)
|
||||||
.replaceAll("{password}", password),
|
.replaceAll("{password}", password)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +119,7 @@ export class EmailService {
|
|||||||
await this.getTransporter()
|
await this.getTransporter()
|
||||||
.sendMail({
|
.sendMail({
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
"smtp.email",
|
"smtp.email"
|
||||||
)}>`,
|
)}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Test email",
|
subject: "Test email",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
@@ -26,24 +25,21 @@ export class FileController {
|
|||||||
@SkipThrottle()
|
@SkipThrottle()
|
||||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async create(
|
async create(
|
||||||
@Query()
|
@Query() query: any,
|
||||||
query: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
chunkIndex: string;
|
|
||||||
totalChunks: string;
|
|
||||||
},
|
|
||||||
@Body() body: string,
|
@Body() body: string,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string
|
||||||
) {
|
) {
|
||||||
const { id, name, chunkIndex, totalChunks } = query;
|
const { id, name, chunkIndex, totalChunks } = query;
|
||||||
|
|
||||||
// Data can be empty if the file is empty
|
// Data can be empty if the file is empty
|
||||||
|
const data = body.toString().split(",")[1] ?? "";
|
||||||
|
|
||||||
return await this.fileService.create(
|
return await this.fileService.create(
|
||||||
body,
|
data,
|
||||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||||
{ id, name },
|
{ id, name },
|
||||||
shareId,
|
shareId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +47,7 @@ export class FileController {
|
|||||||
@UseGuards(FileSecurityGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string
|
||||||
) {
|
) {
|
||||||
const zip = this.fileService.getZip(shareId);
|
const zip = this.fileService.getZip(shareId);
|
||||||
res.set({
|
res.set({
|
||||||
@@ -68,14 +64,13 @@ export class FileController {
|
|||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string,
|
@Param("fileId") fileId: string,
|
||||||
@Query("download") download = "true",
|
@Query("download") download = "true"
|
||||||
) {
|
) {
|
||||||
const file = await this.fileService.get(shareId, fileId);
|
const file = await this.fileService.get(shareId, fileId);
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
"Content-Type": file.metaData.mimeType,
|
"Content-Type": file.metaData.mimeType,
|
||||||
"Content-Length": file.metaData.size,
|
"Content-Length": file.metaData.size,
|
||||||
"Content-Security-Policy": "script-src 'none'",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (download === "true") {
|
if (download === "true") {
|
||||||
@@ -86,14 +81,4 @@ export class FileController {
|
|||||||
|
|
||||||
return new StreamableFile(file.file);
|
return new StreamableFile(file.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":fileId")
|
|
||||||
@SkipThrottle()
|
|
||||||
@UseGuards(ShareOwnerGuard)
|
|
||||||
async remove(
|
|
||||||
@Param("fileId") fileId: string,
|
|
||||||
@Param("shareId") shareId: string,
|
|
||||||
) {
|
|
||||||
await this.fileService.remove(shareId, fileId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ export class FileService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
data: string,
|
data: string,
|
||||||
chunk: { index: number; total: number },
|
chunk: { index: number; total: number },
|
||||||
file: { id?: string; name: string },
|
file: { id?: string; name: string },
|
||||||
shareId: string,
|
shareId: string
|
||||||
) {
|
) {
|
||||||
if (!file.id) file.id = crypto.randomUUID();
|
if (!file.id) file.id = crypto.randomUUID();
|
||||||
|
|
||||||
@@ -40,14 +40,14 @@ export class FileService {
|
|||||||
let diskFileSize: number;
|
let diskFileSize: number;
|
||||||
try {
|
try {
|
||||||
diskFileSize = fs.statSync(
|
diskFileSize = fs.statSync(
|
||||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
|
||||||
).size;
|
).size;
|
||||||
} catch {
|
} catch {
|
||||||
diskFileSize = 0;
|
diskFileSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||||
const chunkSize = this.config.get("share.chunkSize");
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||||
|
|
||||||
if (expectedChunkIndex != chunk.index)
|
if (expectedChunkIndex != chunk.index)
|
||||||
@@ -62,7 +62,7 @@ export class FileService {
|
|||||||
// Check if share size limit is exceeded
|
// Check if share size limit is exceeded
|
||||||
const fileSizeSum = share.files.reduce(
|
const fileSizeSum = share.files.reduce(
|
||||||
(n, { size }) => n + parseInt(size),
|
(n, { size }) => n + parseInt(size),
|
||||||
0,
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||||
@@ -74,23 +74,23 @@ export class FileService {
|
|||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Max share size exceeded",
|
"Max share size exceeded",
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
HttpStatus.PAYLOAD_TOO_LARGE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
buffer,
|
buffer
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLastChunk = chunk.index == chunk.total - 1;
|
const isLastChunk = chunk.index == chunk.total - 1;
|
||||||
if (isLastChunk) {
|
if (isLastChunk) {
|
||||||
fs.renameSync(
|
fs.renameSync(
|
||||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||||
);
|
);
|
||||||
const fileSize = fs.statSync(
|
const fileSize = fs.statSync(
|
||||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||||
).size;
|
).size;
|
||||||
await this.prisma.file.create({
|
await this.prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -124,18 +124,6 @@ export class FileService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(shareId: string, fileId: string) {
|
|
||||||
const fileMetaData = await this.prisma.file.findUnique({
|
|
||||||
where: { id: fileId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
|
||||||
|
|
||||||
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
|
||||||
|
|
||||||
await this.prisma.file.delete({ where: { id: fileId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAllFiles(shareId: string) {
|
async deleteAllFiles(shareId: string) {
|
||||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ import * as moment from "moment";
|
|||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private _shareService: ShareService,
|
private _shareService: ShareService,
|
||||||
private _prisma: PrismaService,
|
private _prisma: PrismaService
|
||||||
_config: ConfigService,
|
|
||||||
) {
|
) {
|
||||||
super(_shareService, _prisma, _config);
|
super(_shareService, _prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
@@ -26,7 +24,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
|||||||
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId",
|
"shareId"
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
@@ -54,7 +52,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
|||||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Maximum views exceeded",
|
"Maximum views exceeded",
|
||||||
"share_max_views_exceeded",
|
"share_max_views_exceeded"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class JobsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private fileService: FileService,
|
private fileService: FileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
@@ -56,33 +56,11 @@ export class JobsService {
|
|||||||
|
|
||||||
if (expiredReverseShares.length > 0) {
|
if (expiredReverseShares.length > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
`Deleted ${expiredReverseShares.length} expired reverse shares`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 */6 * * *")
|
|
||||||
async deleteUnfinishedShares() {
|
|
||||||
const unfinishedShares = await this.prisma.share.findMany({
|
|
||||||
where: {
|
|
||||||
createdAt: { lt: moment().subtract(1, "day").toDate() },
|
|
||||||
uploadLocked: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const unfinishedShare of unfinishedShares) {
|
|
||||||
await this.prisma.share.delete({
|
|
||||||
where: { id: unfinishedShare.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.fileService.deleteAllFiles(unfinishedShare.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unfinishedShares.length > 0) {
|
|
||||||
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Cron("0 0 * * *")
|
@Cron("0 0 * * *")
|
||||||
deleteTemporaryFiles() {
|
deleteTemporaryFiles() {
|
||||||
let filesDeleted = 0;
|
let filesDeleted = 0;
|
||||||
@@ -99,7 +77,7 @@ export class JobsService {
|
|||||||
|
|
||||||
for (const file of temporaryFiles) {
|
for (const file of temporaryFiles) {
|
||||||
const stats = fs.statSync(
|
const stats = fs.statSync(
|
||||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
|
||||||
);
|
);
|
||||||
const isOlderThanOneDay = moment(stats.mtime)
|
const isOlderThanOneDay = moment(stats.mtime)
|
||||||
.add(1, "day")
|
.add(1, "day")
|
||||||
@@ -115,7 +93,7 @@ export class JobsService {
|
|||||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("1 * * * *")
|
@Cron("0 * * * *")
|
||||||
async deleteExpiredTokens() {
|
async deleteExpiredTokens() {
|
||||||
const { count: refreshTokenCount } =
|
const { count: refreshTokenCount } =
|
||||||
await this.prisma.refreshToken.deleteMany({
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import {
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
ClassSerializerInterceptor,
|
|
||||||
Logger,
|
|
||||||
ValidationPipe,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import * as bodyParser from "body-parser";
|
import * as bodyParser from "body-parser";
|
||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { ConfigService } from "./config/config.service";
|
|
||||||
import { DATA_DIRECTORY } from "./constants";
|
import { DATA_DIRECTORY } from "./constants";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -19,16 +13,7 @@ async function bootstrap() {
|
|||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
const config = app.get<ConfigService>(ConfigService);
|
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||||
|
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const chunkSize = config.get("share.chunkSize");
|
|
||||||
bodyParser.raw({
|
|
||||||
type: "application/octet-stream",
|
|
||||||
limit: `${chunkSize}B`,
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
@@ -49,8 +34,5 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||||
|
|
||||||
const logger = new Logger("UnhandledAsyncError");
|
|
||||||
process.on("unhandledRejection", (e) => logger.error(e));
|
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class OAuthCallbackDto {
|
|
||||||
@IsString()
|
|
||||||
code: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export interface OAuthSignInDto {
|
|
||||||
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
|
|
||||||
providerId: string;
|
|
||||||
providerUsername: string;
|
|
||||||
email: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export class ErrorPageException extends Error {
|
|
||||||
/**
|
|
||||||
* Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix)
|
|
||||||
* @param key i18n key of message
|
|
||||||
* @param redirect redirect url
|
|
||||||
* @param params message params (key)
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
public readonly key: string = "default",
|
|
||||||
public readonly redirect?: string,
|
|
||||||
public readonly params?: string[],
|
|
||||||
) {
|
|
||||||
super("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
|
||||||
|
|
||||||
@Catch(ErrorPageException)
|
|
||||||
export class ErrorPageExceptionFilter implements ExceptionFilter {
|
|
||||||
private readonly logger = new Logger(ErrorPageExceptionFilter.name);
|
|
||||||
|
|
||||||
constructor(private config: ConfigService) {}
|
|
||||||
|
|
||||||
catch(exception: ErrorPageException, host: ArgumentsHost) {
|
|
||||||
this.logger.error(
|
|
||||||
JSON.stringify({
|
|
||||||
error: exception.key,
|
|
||||||
params: exception.params,
|
|
||||||
redirect: exception.redirect,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse();
|
|
||||||
|
|
||||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
|
||||||
url.searchParams.set("error", exception.key);
|
|
||||||
if (exception.redirect) {
|
|
||||||
url.searchParams.set("redirect", exception.redirect);
|
|
||||||
} else {
|
|
||||||
const redirect = ctx.getRequest().cookies.access_token
|
|
||||||
? "/account"
|
|
||||||
: "/auth/signIn";
|
|
||||||
url.searchParams.set("redirect", redirect);
|
|
||||||
}
|
|
||||||
if (exception.params) {
|
|
||||||
url.searchParams.set("params", exception.params.join(","));
|
|
||||||
}
|
|
||||||
|
|
||||||
response.redirect(url.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
ArgumentsHost,
|
|
||||||
Catch,
|
|
||||||
ExceptionFilter,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
|
|
||||||
@Catch(HttpException)
|
|
||||||
export class OAuthExceptionFilter implements ExceptionFilter {
|
|
||||||
private errorKeys: Record<string, string> = {
|
|
||||||
access_denied: "access_denied",
|
|
||||||
expired_token: "expired_token",
|
|
||||||
};
|
|
||||||
private readonly logger = new Logger(OAuthExceptionFilter.name);
|
|
||||||
|
|
||||||
constructor(private config: ConfigService) {}
|
|
||||||
|
|
||||||
catch(exception: HttpException, host: ArgumentsHost) {
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse();
|
|
||||||
const request = ctx.getRequest();
|
|
||||||
|
|
||||||
this.logger.error(exception.message);
|
|
||||||
this.logger.error(
|
|
||||||
"Request query: " + JSON.stringify(request.query, null, 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
const key = this.errorKeys[request.query.error] || "default";
|
|
||||||
|
|
||||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
|
||||||
url.searchParams.set("redirect", "/account");
|
|
||||||
url.searchParams.set("error", key);
|
|
||||||
|
|
||||||
response.redirect(url.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OAuthGuard implements CanActivate {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const provider = request.params.provider;
|
|
||||||
return request.query.state === request.cookies[`oauth_${provider}_state`];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ProviderGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private config: ConfigService,
|
|
||||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
|
||||||
) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const provider = request.params.provider;
|
|
||||||
return (
|
|
||||||
this.platforms.includes(provider) &&
|
|
||||||
this.config.get(`oauth.${provider}-enabled`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UseFilters,
|
|
||||||
UseGuards,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import { Request, Response } from "express";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { AuthService } from "../auth/auth.service";
|
|
||||||
import { GetUser } from "../auth/decorator/getUser.decorator";
|
|
||||||
import { JwtGuard } from "../auth/guard/jwt.guard";
|
|
||||||
import { ConfigService } from "../config/config.service";
|
|
||||||
import { OAuthCallbackDto } from "./dto/oauthCallback.dto";
|
|
||||||
import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter";
|
|
||||||
import { OAuthGuard } from "./guard/oauth.guard";
|
|
||||||
import { ProviderGuard } from "./guard/provider.guard";
|
|
||||||
import { OAuthService } from "./oauth.service";
|
|
||||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
|
||||||
import { OAuthExceptionFilter } from "./filter/oauthException.filter";
|
|
||||||
|
|
||||||
@Controller("oauth")
|
|
||||||
export class OAuthController {
|
|
||||||
constructor(
|
|
||||||
private authService: AuthService,
|
|
||||||
private oauthService: OAuthService,
|
|
||||||
private config: ConfigService,
|
|
||||||
@Inject("OAUTH_PROVIDERS")
|
|
||||||
private providers: Record<string, OAuthProvider<unknown>>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get("available")
|
|
||||||
available() {
|
|
||||||
return this.oauthService.available();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("status")
|
|
||||||
@UseGuards(JwtGuard)
|
|
||||||
async status(@GetUser() user: User) {
|
|
||||||
return this.oauthService.status(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("auth/:provider")
|
|
||||||
@UseGuards(ProviderGuard)
|
|
||||||
@UseFilters(ErrorPageExceptionFilter)
|
|
||||||
async auth(
|
|
||||||
@Param("provider") provider: string,
|
|
||||||
@Res({ passthrough: true }) response: Response,
|
|
||||||
) {
|
|
||||||
const state = nanoid(16);
|
|
||||||
const url = await this.providers[provider].getAuthEndpoint(state);
|
|
||||||
response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" });
|
|
||||||
response.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("callback/:provider")
|
|
||||||
@UseGuards(ProviderGuard, OAuthGuard)
|
|
||||||
@UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter)
|
|
||||||
async callback(
|
|
||||||
@Param("provider") provider: string,
|
|
||||||
@Query() query: OAuthCallbackDto,
|
|
||||||
@Req() request: Request,
|
|
||||||
@Res({ passthrough: true }) response: Response,
|
|
||||||
) {
|
|
||||||
const oauthToken = await this.providers[provider].getToken(query);
|
|
||||||
const user = await this.providers[provider].getUserInfo(oauthToken, query);
|
|
||||||
const id = await this.authService.getIdOfCurrentUser(request);
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
await this.oauthService.link(
|
|
||||||
id,
|
|
||||||
provider,
|
|
||||||
user.providerId,
|
|
||||||
user.providerUsername,
|
|
||||||
);
|
|
||||||
response.redirect(this.config.get("general.appUrl") + "/account");
|
|
||||||
} else {
|
|
||||||
const token: {
|
|
||||||
accessToken?: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
loginToken?: string;
|
|
||||||
} = await this.oauthService.signIn(user, request.ip);
|
|
||||||
if (token.accessToken) {
|
|
||||||
this.authService.addTokensToResponse(
|
|
||||||
response,
|
|
||||||
token.refreshToken,
|
|
||||||
token.accessToken,
|
|
||||||
);
|
|
||||||
response.redirect(this.config.get("general.appUrl"));
|
|
||||||
} else {
|
|
||||||
response.redirect(
|
|
||||||
this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("unlink/:provider")
|
|
||||||
@UseGuards(JwtGuard, ProviderGuard)
|
|
||||||
@UseFilters(ErrorPageExceptionFilter)
|
|
||||||
unlink(@GetUser() user: User, @Param("provider") provider: string) {
|
|
||||||
return this.oauthService.unlink(user, provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { OAuthController } from "./oauth.controller";
|
|
||||||
import { OAuthService } from "./oauth.service";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { GitHubProvider } from "./provider/github.provider";
|
|
||||||
import { GoogleProvider } from "./provider/google.provider";
|
|
||||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
|
||||||
import { OidcProvider } from "./provider/oidc.provider";
|
|
||||||
import { DiscordProvider } from "./provider/discord.provider";
|
|
||||||
import { MicrosoftProvider } from "./provider/microsoft.provider";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [OAuthController],
|
|
||||||
providers: [
|
|
||||||
OAuthService,
|
|
||||||
GitHubProvider,
|
|
||||||
GoogleProvider,
|
|
||||||
MicrosoftProvider,
|
|
||||||
DiscordProvider,
|
|
||||||
OidcProvider,
|
|
||||||
{
|
|
||||||
provide: "OAUTH_PROVIDERS",
|
|
||||||
useFactory(
|
|
||||||
github: GitHubProvider,
|
|
||||||
google: GoogleProvider,
|
|
||||||
microsoft: MicrosoftProvider,
|
|
||||||
discord: DiscordProvider,
|
|
||||||
oidc: OidcProvider,
|
|
||||||
): Record<string, OAuthProvider<unknown>> {
|
|
||||||
return {
|
|
||||||
github,
|
|
||||||
google,
|
|
||||||
microsoft,
|
|
||||||
discord,
|
|
||||||
oidc,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
inject: [
|
|
||||||
GitHubProvider,
|
|
||||||
GoogleProvider,
|
|
||||||
MicrosoftProvider,
|
|
||||||
DiscordProvider,
|
|
||||||
OidcProvider,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: "OAUTH_PLATFORMS",
|
|
||||||
useFactory(providers: Record<string, OAuthProvider<unknown>>): string[] {
|
|
||||||
return Object.keys(providers);
|
|
||||||
},
|
|
||||||
inject: ["OAUTH_PROVIDERS"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
imports: [AuthModule],
|
|
||||||
})
|
|
||||||
export class OAuthModule {}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { AuthService } from "../auth/auth.service";
|
|
||||||
import { ConfigService } from "../config/config.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
|
||||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OAuthService {
|
|
||||||
constructor(
|
|
||||||
private prisma: PrismaService,
|
|
||||||
private config: ConfigService,
|
|
||||||
private auth: AuthService,
|
|
||||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
|
||||||
) {}
|
|
||||||
private readonly logger = new Logger(OAuthService.name);
|
|
||||||
|
|
||||||
available(): string[] {
|
|
||||||
return this.platforms
|
|
||||||
.map((platform) => [
|
|
||||||
platform,
|
|
||||||
this.config.get(`oauth.${platform}-enabled`),
|
|
||||||
])
|
|
||||||
.filter(([_, enabled]) => enabled)
|
|
||||||
.map(([platform, _]) => platform);
|
|
||||||
}
|
|
||||||
|
|
||||||
async status(user: User) {
|
|
||||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
|
||||||
select: {
|
|
||||||
provider: true,
|
|
||||||
providerUsername: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
|
|
||||||
}
|
|
||||||
|
|
||||||
async signIn(user: OAuthSignInDto, ip: string) {
|
|
||||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
|
||||||
where: {
|
|
||||||
provider: user.provider,
|
|
||||||
providerUserId: user.providerId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (oauthUser) {
|
|
||||||
await this.updateIsAdmin(user);
|
|
||||||
const updatedUser = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
|
||||||
return this.auth.generateToken(updatedUser, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.signUp(user, ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
async link(
|
|
||||||
userId: string,
|
|
||||||
provider: string,
|
|
||||||
providerUserId: string,
|
|
||||||
providerUsername: string,
|
|
||||||
) {
|
|
||||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
|
||||||
where: {
|
|
||||||
provider,
|
|
||||||
providerUserId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (oauthUser) {
|
|
||||||
throw new ErrorPageException("already_linked", "/account", [
|
|
||||||
`provider_${provider}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.oAuthUser.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
provider,
|
|
||||||
providerUsername,
|
|
||||||
providerUserId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async unlink(user: User, provider: string) {
|
|
||||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (oauthUser) {
|
|
||||||
await this.prisma.oAuthUser.delete({
|
|
||||||
where: {
|
|
||||||
id: oauthUser.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new ErrorPageException("not_linked", "/account", [provider]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAvailableUsername(preferredUsername: string) {
|
|
||||||
// only remove + and - from preferred username for now (maybe not enough)
|
|
||||||
let username = preferredUsername.replace(/[+-]/g, "").substring(0, 20);
|
|
||||||
while (true) {
|
|
||||||
const user = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
username: username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user) {
|
|
||||||
username = username + "_" + nanoid(10).replaceAll("-", "");
|
|
||||||
} else {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async signUp(user: OAuthSignInDto, ip: string) {
|
|
||||||
// register
|
|
||||||
if (!this.config.get("oauth.allowRegistration")) {
|
|
||||||
throw new ErrorPageException("no_user", "/auth/signIn", [
|
|
||||||
`provider_${user.provider}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.email) {
|
|
||||||
throw new ErrorPageException("no_email", "/auth/signIn", [
|
|
||||||
`provider_${user.provider}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser: User = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
await this.prisma.oAuthUser.create({
|
|
||||||
data: {
|
|
||||||
provider: user.provider,
|
|
||||||
providerUserId: user.providerId.toString(),
|
|
||||||
providerUsername: user.providerUsername,
|
|
||||||
userId: existingUser.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.updateIsAdmin(user);
|
|
||||||
return this.auth.generateToken(existingUser, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.auth.signUp(
|
|
||||||
{
|
|
||||||
email: user.email,
|
|
||||||
username: await this.getAvailableUsername(user.providerUsername),
|
|
||||||
password: null,
|
|
||||||
},
|
|
||||||
ip,
|
|
||||||
user.isAdmin,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.prisma.oAuthUser.create({
|
|
||||||
data: {
|
|
||||||
provider: user.provider,
|
|
||||||
providerUserId: user.providerId.toString(),
|
|
||||||
providerUsername: user.providerUsername,
|
|
||||||
userId: result.user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateIsAdmin(user: OAuthSignInDto) {
|
|
||||||
if ("isAdmin" in user)
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: {
|
|
||||||
email: user.email,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
isAdmin: user.isAdmin,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
|
||||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
|
||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
|
||||||
@Injectable()
|
|
||||||
export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|
||||||
constructor(private config: ConfigService) {}
|
|
||||||
|
|
||||||
getAuthEndpoint(state: string): Promise<string> {
|
|
||||||
let scope = "identify email";
|
|
||||||
if (this.config.get("oauth.discord-limitedGuild")) {
|
|
||||||
scope += " guilds";
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
"https://discord.com/api/oauth2/authorize?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
client_id: this.config.get("oauth.discord-clientId"),
|
|
||||||
redirect_uri:
|
|
||||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
|
||||||
response_type: "code",
|
|
||||||
state,
|
|
||||||
scope,
|
|
||||||
}).toString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAuthorizationHeader() {
|
|
||||||
return (
|
|
||||||
"Basic " +
|
|
||||||
Buffer.from(
|
|
||||||
this.config.get("oauth.discord-clientId") +
|
|
||||||
":" +
|
|
||||||
this.config.get("oauth.discord-clientSecret"),
|
|
||||||
).toString("base64")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<DiscordToken>> {
|
|
||||||
const res = await fetch("https://discord.com/api/v10/oauth2/token", {
|
|
||||||
method: "post",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Authorization: this.getAuthorizationHeader(),
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code: query.code,
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
redirect_uri:
|
|
||||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const token = (await res.json()) as DiscordToken;
|
|
||||||
return {
|
|
||||||
accessToken: token.access_token,
|
|
||||||
refreshToken: token.refresh_token,
|
|
||||||
expiresIn: token.expires_in,
|
|
||||||
scope: token.scope,
|
|
||||||
tokenType: token.token_type,
|
|
||||||
rawToken: token,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfo(token: OAuthToken<DiscordToken>): Promise<OAuthSignInDto> {
|
|
||||||
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
|
||||||
method: "get",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const user = (await res.json()) as DiscordUser;
|
|
||||||
if (user.verified === false) {
|
|
||||||
throw new ErrorPageException("unverified_account", undefined, [
|
|
||||||
"provider_discord",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const guild = this.config.get("oauth.discord-limitedGuild");
|
|
||||||
if (guild) {
|
|
||||||
await this.checkLimitedGuild(token, guild);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: "discord",
|
|
||||||
providerId: user.id,
|
|
||||||
providerUsername: user.global_name ?? user.username,
|
|
||||||
email: user.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
|
||||||
method: "get",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const guilds = (await res.json()) as DiscordPartialGuild[];
|
|
||||||
if (!guilds.some((guild) => guild.id === guildId)) {
|
|
||||||
throw new ErrorPageException("user_not_allowed");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new ErrorPageException("user_not_allowed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscordToken {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
refresh_token: string;
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscordUser {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
global_name: string;
|
|
||||||
email: string;
|
|
||||||
verified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscordPartialGuild {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
owner: boolean;
|
|
||||||
permissions: string;
|
|
||||||
features: string[];
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import { Logger } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
|
||||||
import { Cache } from "cache-manager";
|
|
||||||
import * as jmespath from "jmespath";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
|
||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
|
||||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
|
||||||
|
|
||||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|
||||||
protected discoveryUri: string;
|
|
||||||
private configuration: OidcConfigurationCache;
|
|
||||||
private jwk: OidcJwkCache;
|
|
||||||
private logger: Logger = new Logger(
|
|
||||||
Object.getPrototypeOf(this).constructor.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
protected constructor(
|
|
||||||
protected name: string,
|
|
||||||
protected keyOfConfigUpdateEvents: string[],
|
|
||||||
protected config: ConfigService,
|
|
||||||
protected jwtService: JwtService,
|
|
||||||
protected cache: Cache,
|
|
||||||
) {
|
|
||||||
this.discoveryUri = this.getDiscoveryUri();
|
|
||||||
this.config.addListener("update", (key: string) => {
|
|
||||||
if (this.keyOfConfigUpdateEvents.includes(key)) {
|
|
||||||
this.deinit();
|
|
||||||
this.discoveryUri = this.getDiscoveryUri();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getRedirectUri(): string {
|
|
||||||
return `${this.config.get("general.appUrl")}/api/oauth/callback/${
|
|
||||||
this.name
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConfiguration(): Promise<OidcConfiguration> {
|
|
||||||
if (!this.configuration || this.configuration.expires < Date.now()) {
|
|
||||||
await this.fetchConfiguration();
|
|
||||||
}
|
|
||||||
return this.configuration.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getJwk(): Promise<OidcJwk[]> {
|
|
||||||
if (!this.jwk || this.jwk.expires < Date.now()) {
|
|
||||||
await this.fetchJwk();
|
|
||||||
}
|
|
||||||
return this.jwk.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAuthEndpoint(state: string) {
|
|
||||||
const configuration = await this.getConfiguration();
|
|
||||||
const endpoint = configuration.authorization_endpoint;
|
|
||||||
|
|
||||||
const nonce = nanoid();
|
|
||||||
await this.cache.set(
|
|
||||||
`oauth-${this.name}-nonce-${state}`,
|
|
||||||
nonce,
|
|
||||||
1000 * 60 * 5,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
endpoint +
|
|
||||||
"?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
redirect_uri: this.getRedirectUri(),
|
|
||||||
state,
|
|
||||||
nonce,
|
|
||||||
}).toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<OidcToken>> {
|
|
||||||
const configuration = await this.getConfiguration();
|
|
||||||
const endpoint = configuration.token_endpoint;
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
|
||||||
client_secret: this.config.get(`oauth.${this.name}-clientSecret`),
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code: query.code,
|
|
||||||
redirect_uri: this.getRedirectUri(),
|
|
||||||
}).toString(),
|
|
||||||
});
|
|
||||||
const token = (await res.json()) as OidcToken;
|
|
||||||
return {
|
|
||||||
accessToken: token.access_token,
|
|
||||||
expiresIn: token.expires_in,
|
|
||||||
idToken: token.id_token,
|
|
||||||
refreshToken: token.refresh_token,
|
|
||||||
tokenType: token.token_type,
|
|
||||||
rawToken: token,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfo(
|
|
||||||
token: OAuthToken<OidcToken>,
|
|
||||||
query: OAuthCallbackDto,
|
|
||||||
claim?: string,
|
|
||||||
roleConfig?: {
|
|
||||||
path?: string;
|
|
||||||
generalAccess?: string;
|
|
||||||
adminAccess?: string;
|
|
||||||
},
|
|
||||||
): Promise<OAuthSignInDto> {
|
|
||||||
const idTokenData = this.decodeIdToken(token.idToken);
|
|
||||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
|
||||||
|
|
||||||
const key = `oauth-${this.name}-nonce-${query.state}`;
|
|
||||||
const nonce = await this.cache.get(key);
|
|
||||||
await this.cache.del(key);
|
|
||||||
if (nonce !== idTokenData.nonce) {
|
|
||||||
this.logger.error(
|
|
||||||
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
|
|
||||||
);
|
|
||||||
throw new ErrorPageException("invalid_token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = claim
|
|
||||||
? idTokenData[claim]
|
|
||||||
: idTokenData.preferred_username ||
|
|
||||||
idTokenData.name ||
|
|
||||||
idTokenData.nickname;
|
|
||||||
|
|
||||||
let isAdmin: boolean;
|
|
||||||
|
|
||||||
if (roleConfig?.path) {
|
|
||||||
// A path to read roles from the token is configured
|
|
||||||
let roles: string[] | null;
|
|
||||||
try {
|
|
||||||
roles = jmespath.search(idTokenData, roleConfig.path);
|
|
||||||
} catch (e) {
|
|
||||||
roles = null;
|
|
||||||
}
|
|
||||||
if (Array.isArray(roles)) {
|
|
||||||
// Roles are found in the token
|
|
||||||
if (
|
|
||||||
roleConfig.generalAccess &&
|
|
||||||
!roles.includes(roleConfig.generalAccess)
|
|
||||||
) {
|
|
||||||
// Role for general access is configured and the user does not have it
|
|
||||||
this.logger.error(
|
|
||||||
`User roles ${roles} do not include ${roleConfig.generalAccess}`,
|
|
||||||
);
|
|
||||||
throw new ErrorPageException("user_not_allowed");
|
|
||||||
}
|
|
||||||
if (roleConfig.adminAccess) {
|
|
||||||
// Role for admin access is configured
|
|
||||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.error(
|
|
||||||
`Roles not found at path ${roleConfig.path} in ID Token ${JSON.stringify(
|
|
||||||
idTokenData,
|
|
||||||
undefined,
|
|
||||||
2,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
throw new ErrorPageException("user_not_allowed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
this.logger.error(
|
|
||||||
`Can not get username from ID Token ${JSON.stringify(
|
|
||||||
idTokenData,
|
|
||||||
undefined,
|
|
||||||
2,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
throw new ErrorPageException("cannot_get_user_info", undefined, [
|
|
||||||
`provider_${this.name}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: this.name as any,
|
|
||||||
email: idTokenData.email,
|
|
||||||
providerId: idTokenData.sub,
|
|
||||||
providerUsername: username,
|
|
||||||
...(isAdmin !== undefined && { isAdmin }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract getDiscoveryUri(): string;
|
|
||||||
|
|
||||||
private async fetchConfiguration(): Promise<void> {
|
|
||||||
const res = await fetch(this.discoveryUri);
|
|
||||||
const expires = res.headers.has("expires")
|
|
||||||
? new Date(res.headers.get("expires")).getTime()
|
|
||||||
: Date.now() + 1000 * 60 * 60 * 24;
|
|
||||||
this.configuration = {
|
|
||||||
expires,
|
|
||||||
data: (await res.json()) as OidcConfiguration,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchJwk(): Promise<void> {
|
|
||||||
const configuration = await this.getConfiguration();
|
|
||||||
const res = await fetch(configuration.jwks_uri);
|
|
||||||
const expires = res.headers.has("expires")
|
|
||||||
? new Date(res.headers.get("expires")).getTime()
|
|
||||||
: Date.now() + 1000 * 60 * 60 * 24;
|
|
||||||
this.jwk = {
|
|
||||||
expires,
|
|
||||||
data: (await res.json())["keys"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private deinit() {
|
|
||||||
this.discoveryUri = undefined;
|
|
||||||
this.configuration = undefined;
|
|
||||||
this.jwk = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeIdToken(idToken: string): OidcIdToken {
|
|
||||||
return this.jwtService.decode(idToken) as OidcIdToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OidcCache<T> {
|
|
||||||
expires: number;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OidcConfiguration {
|
|
||||||
issuer: string;
|
|
||||||
authorization_endpoint: string;
|
|
||||||
token_endpoint: string;
|
|
||||||
userinfo_endpoint?: string;
|
|
||||||
jwks_uri: string;
|
|
||||||
response_types_supported: string[];
|
|
||||||
id_token_signing_alg_values_supported: string[];
|
|
||||||
scopes_supported?: string[];
|
|
||||||
claims_supported?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OidcJwk {
|
|
||||||
e: string;
|
|
||||||
alg: string;
|
|
||||||
kid: string;
|
|
||||||
use: string;
|
|
||||||
kty: string;
|
|
||||||
n: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OidcConfigurationCache = OidcCache<OidcConfiguration>;
|
|
||||||
|
|
||||||
export type OidcJwkCache = OidcCache<OidcJwk[]>;
|
|
||||||
|
|
||||||
export interface OidcToken {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
id_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OidcIdToken {
|
|
||||||
iss: string;
|
|
||||||
sub: string;
|
|
||||||
exp: number;
|
|
||||||
iat: number;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
nickname: string;
|
|
||||||
preferred_username: string;
|
|
||||||
nonce: string;
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
|
||||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
|
||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
|
||||||
constructor(private config: ConfigService) {}
|
|
||||||
|
|
||||||
getAuthEndpoint(state: string): Promise<string> {
|
|
||||||
return Promise.resolve(
|
|
||||||
"https://github.com/login/oauth/authorize?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
client_id: this.config.get("oauth.github-clientId"),
|
|
||||||
redirect_uri:
|
|
||||||
this.config.get("general.appUrl") + "/api/oauth/callback/github",
|
|
||||||
state: state,
|
|
||||||
scope: "user:email",
|
|
||||||
}).toString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<GitHubToken>> {
|
|
||||||
const res = await fetch(
|
|
||||||
"https://github.com/login/oauth/access_token?" +
|
|
||||||
new URLSearchParams({
|
|
||||||
client_id: this.config.get("oauth.github-clientId"),
|
|
||||||
client_secret: this.config.get("oauth.github-clientSecret"),
|
|
||||||
code: query.code,
|
|
||||||
}).toString(),
|
|
||||||
{
|
|
||||||
method: "post",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const token = (await res.json()) as GitHubToken;
|
|
||||||
return {
|
|
||||||
accessToken: token.access_token,
|
|
||||||
tokenType: token.token_type,
|
|
||||||
scope: token.scope,
|
|
||||||
rawToken: token,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
|
|
||||||
if (!token.scope.includes("user:email")) {
|
|
||||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
|
||||||
}
|
|
||||||
const user = await this.getGitHubUser(token);
|
|
||||||
const email = await this.getGitHubEmail(token);
|
|
||||||
if (!email) {
|
|
||||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: "github",
|
|
||||||
providerId: user.id.toString(),
|
|
||||||
providerUsername: user.name ?? user.login,
|
|
||||||
email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getGitHubUser(
|
|
||||||
token: OAuthToken<GitHubToken>,
|
|
||||||
): Promise<GitHubUser> {
|
|
||||||
const res = await fetch("https://api.github.com/user", {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.github+json",
|
|
||||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (await res.json()) as GitHubUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getGitHubEmail(
|
|
||||||
token: OAuthToken<GitHubToken>,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const res = await fetch("https://api.github.com/user/public_emails", {
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.github+json",
|
|
||||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emails = (await res.json()) as GitHubEmail[];
|
|
||||||
return emails.find((e) => e.primary && e.verified)?.email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitHubToken {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitHubUser {
|
|
||||||
login: string;
|
|
||||||
id: number;
|
|
||||||
name?: string;
|
|
||||||
email?: string; // this filed seems only return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitHubEmail {
|
|
||||||
email: string;
|
|
||||||
primary: boolean;
|
|
||||||
verified: boolean;
|
|
||||||
visibility: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
|
||||||
import { Inject, Injectable } from "@nestjs/common";
|
|
||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { Cache } from "cache-manager";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GoogleProvider extends GenericOidcProvider {
|
|
||||||
constructor(
|
|
||||||
config: ConfigService,
|
|
||||||
jwtService: JwtService,
|
|
||||||
@Inject(CACHE_MANAGER) cache: Cache,
|
|
||||||
) {
|
|
||||||
super("google", ["oauth.google-enabled"], config, jwtService, cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDiscoveryUri(): string {
|
|
||||||
return "https://accounts.google.com/.well-known/openid-configuration";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
|
||||||
import { Inject, Injectable } from "@nestjs/common";
|
|
||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { Cache } from "cache-manager";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MicrosoftProvider extends GenericOidcProvider {
|
|
||||||
constructor(
|
|
||||||
config: ConfigService,
|
|
||||||
jwtService: JwtService,
|
|
||||||
@Inject(CACHE_MANAGER) cache: Cache,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
"microsoft",
|
|
||||||
["oauth.microsoft-enabled", "oauth.microsoft-tenant"],
|
|
||||||
config,
|
|
||||||
jwtService,
|
|
||||||
cache,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDiscoveryUri(): string {
|
|
||||||
return `https://login.microsoftonline.com/${this.config.get(
|
|
||||||
"oauth.microsoft-tenant",
|
|
||||||
)}/v2.0/.well-known/openid-configuration`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typeParam T - type of token
|
|
||||||
* @typeParam C - type of callback query
|
|
||||||
*/
|
|
||||||
export interface OAuthProvider<T, C = OAuthCallbackDto> {
|
|
||||||
getAuthEndpoint(state: string): Promise<string>;
|
|
||||||
|
|
||||||
getToken(query: C): Promise<OAuthToken<T>>;
|
|
||||||
|
|
||||||
getUserInfo(token: OAuthToken<T>, query: C): Promise<OAuthSignInDto>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OAuthToken<T> {
|
|
||||||
accessToken: string;
|
|
||||||
expiresIn?: number;
|
|
||||||
refreshToken?: string;
|
|
||||||
tokenType?: string;
|
|
||||||
scope?: string;
|
|
||||||
idToken?: string;
|
|
||||||
rawToken: T;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { GenericOidcProvider, OidcToken } from "./genericOidc.provider";
|
|
||||||
import { Inject, Injectable } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
|
||||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { Cache } from "cache-manager";
|
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
|
||||||
import { OAuthToken } from "./oauthProvider.interface";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OidcProvider extends GenericOidcProvider {
|
|
||||||
constructor(
|
|
||||||
config: ConfigService,
|
|
||||||
jwtService: JwtService,
|
|
||||||
@Inject(CACHE_MANAGER) protected cache: Cache,
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
"oidc",
|
|
||||||
["oauth.oidc-enabled", "oauth.oidc-discoveryUri"],
|
|
||||||
config,
|
|
||||||
jwtService,
|
|
||||||
cache,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDiscoveryUri(): string {
|
|
||||||
return this.config.get("oauth.oidc-discoveryUri");
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserInfo(
|
|
||||||
token: OAuthToken<OidcToken>,
|
|
||||||
query: OAuthCallbackDto,
|
|
||||||
_?: string,
|
|
||||||
): Promise<OAuthSignInDto> {
|
|
||||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
|
||||||
const rolePath = this.config.get("oauth.oidc-rolePath") || undefined;
|
|
||||||
const roleGeneralAccess =
|
|
||||||
this.config.get("oauth.oidc-roleGeneralAccess") || undefined;
|
|
||||||
const roleAdminAccess =
|
|
||||||
this.config.get("oauth.oidc-roleAdminAccess") || undefined;
|
|
||||||
return super.getUserInfo(token, query, claim, {
|
|
||||||
path: rolePath,
|
|
||||||
generalAccess: roleGeneralAccess,
|
|
||||||
adminAccess: roleAdminAccess,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,4 @@ export class CreateReverseShareDTO {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(1000)
|
@Max(1000)
|
||||||
maxUseCount: number;
|
maxUseCount: number;
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
simplified: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
publicAccess: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ export class ReverseShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
shareExpiration: Date;
|
shareExpiration: Date;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
token: string;
|
|
||||||
|
|
||||||
@Expose()
|
|
||||||
simplified: boolean;
|
|
||||||
|
|
||||||
from(partial: Partial<ReverseShareDTO>) {
|
from(partial: Partial<ReverseShareDTO>) {
|
||||||
return plainToClass(ReverseShareDTO, partial, {
|
return plainToClass(ReverseShareDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
|||||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||||
shares: Omit<
|
shares: Omit<
|
||||||
MyShareDTO,
|
MyShareDTO,
|
||||||
"recipients" | "files" | "from" | "fromList" | "hasPassword" | "size"
|
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||||
>[];
|
>[];
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
|||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ReverseShareTokenWithShares, part, {
|
plainToClass(ReverseShareTokenWithShares, part, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
|
|||||||
export class ReverseShareController {
|
export class ReverseShareController {
|
||||||
constructor(
|
constructor(
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private config: ConfigService,
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -36,12 +36,7 @@ export class ReverseShareController {
|
|||||||
return { token, link };
|
return { token, link };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle({
|
@Throttle(20, 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Get(":reverseShareToken")
|
@Get(":reverseShareToken")
|
||||||
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||||
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||||
@@ -49,7 +44,7 @@ export class ReverseShareController {
|
|||||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||||
|
|
||||||
return new ReverseShareDTO().from(
|
return new ReverseShareDTO().from(
|
||||||
await this.reverseShareService.getByToken(reverseShareToken),
|
await this.reverseShareService.getByToken(reverseShareToken)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +52,7 @@ export class ReverseShareController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getAllByUser(@GetUser() user: User) {
|
async getAllByUser(@GetUser() user: User) {
|
||||||
return new ReverseShareTokenWithShares().fromList(
|
return new ReverseShareTokenWithShares().fromList(
|
||||||
await this.reverseShareService.getAllByUser(user.id),
|
await this.reverseShareService.getAllByUser(user.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as moment from "moment";
|
|||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
|
||||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -11,7 +10,7 @@ export class ReverseShareService {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private fileService: FileService,
|
private fileService: FileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||||
@@ -20,27 +19,16 @@ export class ReverseShareService {
|
|||||||
.add(
|
.add(
|
||||||
data.shareExpiration.split("-")[0],
|
data.shareExpiration.split("-")[0],
|
||||||
data.shareExpiration.split(
|
data.shareExpiration.split(
|
||||||
"-",
|
"-"
|
||||||
)[1] as moment.unitOfTime.DurationConstructor,
|
)[1] as moment.unitOfTime.DurationConstructor
|
||||||
)
|
)
|
||||||
.toDate();
|
.toDate();
|
||||||
|
|
||||||
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
|
|
||||||
if (
|
|
||||||
this.config.get("share.maxExpiration") !== 0 &&
|
|
||||||
parsedExpiration >
|
|
||||||
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Expiration date exceeds maximum expiration date",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalMaxShareSize = this.config.get("share.maxSize");
|
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||||
|
|
||||||
if (globalMaxShareSize < data.maxShareSize)
|
if (globalMaxShareSize < data.maxShareSize)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Max share size can't be greater than ${globalMaxShareSize} bytes.`,
|
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
|
||||||
);
|
);
|
||||||
|
|
||||||
const reverseShare = await this.prisma.reverseShare.create({
|
const reverseShare = await this.prisma.reverseShare.create({
|
||||||
@@ -49,8 +37,6 @@ export class ReverseShareService {
|
|||||||
remainingUses: data.maxUseCount,
|
remainingUses: data.maxUseCount,
|
||||||
maxShareSize: data.maxShareSize,
|
maxShareSize: data.maxShareSize,
|
||||||
sendEmailNotification: data.sendEmailNotification,
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
simplified: data.simplified,
|
|
||||||
publicAccess: data.publicAccess,
|
|
||||||
creatorId,
|
creatorId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
|
||||||
import { Expose, plainToClass } from "class-transformer";
|
|
||||||
import { ShareDTO } from "./share.dto";
|
|
||||||
|
|
||||||
export class AdminShareDTO extends OmitType(ShareDTO, [
|
|
||||||
"files",
|
|
||||||
"from",
|
|
||||||
"fromList",
|
|
||||||
] as const) {
|
|
||||||
@Expose()
|
|
||||||
views: number;
|
|
||||||
|
|
||||||
@Expose()
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
from(partial: Partial<AdminShareDTO>) {
|
|
||||||
return plainToClass(AdminShareDTO, partial, {
|
|
||||||
excludeExtraneousValues: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fromList(partial: Partial<AdminShareDTO>[]) {
|
|
||||||
return partial.map((part) =>
|
|
||||||
plainToClass(AdminShareDTO, part, { excludeExtraneousValues: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,6 @@ export class CreateShareDTO {
|
|||||||
@Length(3, 50)
|
@Length(3, 50)
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Length(3, 30)
|
|
||||||
@IsOptional()
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
expiration: string;
|
expiration: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Expose, plainToClass, Type } from "class-transformer";
|
import { Expose, plainToClass, Type } from "class-transformer";
|
||||||
import { ShareDTO } from "./share.dto";
|
import { ShareDTO } from "./share.dto";
|
||||||
import { FileDTO } from "../../file/dto/file.dto";
|
import {FileDTO} from "../../file/dto/file.dto";
|
||||||
import { OmitType } from "@nestjs/swagger";
|
import {OmitType} from "@nestjs/swagger";
|
||||||
|
|
||||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||||
"files",
|
"files",
|
||||||
@@ -27,7 +27,7 @@ export class MyShareDTO extends OmitType(ShareDTO, [
|
|||||||
|
|
||||||
fromList(partial: Partial<MyShareDTO>[]) {
|
fromList(partial: Partial<MyShareDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }),
|
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,6 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
expiration: Date;
|
expiration: Date;
|
||||||
|
|
||||||
@@ -26,16 +23,13 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
hasPassword: boolean;
|
hasPassword: boolean;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
size: number;
|
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
fromList(partial: Partial<ShareDTO>[]) {
|
fromList(partial: Partial<ShareDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ShareDTO, part, { excludeExtraneousValues: true }),
|
plainToClass(ShareDTO, part, { excludeExtraneousValues: true })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Expose, plainToClass } from "class-transformer";
|
|
||||||
import { ShareDTO } from "./share.dto";
|
|
||||||
|
|
||||||
export class CompletedShareDTO extends ShareDTO {
|
|
||||||
@Expose()
|
|
||||||
notifyReverseShareCreator?: boolean;
|
|
||||||
|
|
||||||
from(partial: Partial<CompletedShareDTO>) {
|
|
||||||
return plainToClass(CompletedShareDTO, partial, {
|
|
||||||
excludeExtraneousValues: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fromList(partial: Partial<CompletedShareDTO>[]) {
|
|
||||||
return partial.map((part) =>
|
|
||||||
plainToClass(CompletedShareDTO, part, { excludeExtraneousValues: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
|||||||
export class CreateShareGuard extends JwtGuard {
|
export class CreateShareGuard extends JwtGuard {
|
||||||
constructor(
|
constructor(
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService
|
||||||
) {
|
) {
|
||||||
super(configService);
|
super(configService);
|
||||||
}
|
}
|
||||||
@@ -20,8 +20,9 @@ export class CreateShareGuard extends JwtGuard {
|
|||||||
|
|
||||||
if (!reverseShareTokenId) return false;
|
if (!reverseShareTokenId) return false;
|
||||||
|
|
||||||
const isReverseShareTokenValid =
|
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
||||||
await this.reverseShareService.isValid(reverseShareTokenId);
|
reverseShareTokenId
|
||||||
|
);
|
||||||
|
|
||||||
return isReverseShareTokenValid;
|
return isReverseShareTokenValid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareOwnerGuard extends JwtGuard {
|
export class ShareOwnerGuard implements CanActivate {
|
||||||
constructor(
|
constructor(private prisma: PrismaService) {}
|
||||||
configService: ConfigService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
) {
|
|
||||||
super(configService);
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId",
|
"shareId"
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
@@ -34,20 +28,8 @@ export class ShareOwnerGuard extends JwtGuard {
|
|||||||
|
|
||||||
if (!share) throw new NotFoundException("Share not found");
|
if (!share) throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
// Run the JWTGuard to set the user
|
|
||||||
await super.canActivate(context);
|
|
||||||
const user = request.user as User;
|
|
||||||
|
|
||||||
// If the user is an admin, allow access
|
|
||||||
if (user?.isAdmin) return true;
|
|
||||||
|
|
||||||
// If it's a anonymous share, allow access
|
|
||||||
if (!share.creatorId) return true;
|
if (!share.creatorId) return true;
|
||||||
|
|
||||||
// If not signed in, deny access
|
return share.creatorId == (request.user as User).id;
|
||||||
if (!user) return false;
|
|
||||||
|
|
||||||
// If the user is the creator of the share, allow access
|
|
||||||
return share.creatorId == user.id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -8,26 +9,20 @@ import { Request } from "express";
|
|||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard extends JwtGuard {
|
export class ShareSecurityGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService
|
||||||
configService: ConfigService,
|
) {}
|
||||||
) {
|
|
||||||
super(configService);
|
|
||||||
}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId",
|
"shareId"
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
@@ -36,7 +31,7 @@ export class ShareSecurityGuard extends JwtGuard {
|
|||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true, reverseShare: true },
|
include: { security: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -49,29 +44,13 @@ export class ShareSecurityGuard extends JwtGuard {
|
|||||||
if (share.security?.password && !shareToken)
|
if (share.security?.password && !shareToken)
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"This share is password protected",
|
"This share is password protected",
|
||||||
"share_password_required",
|
"share_password_required"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Share token required",
|
"Share token required",
|
||||||
"share_token_required",
|
"share_token_required"
|
||||||
);
|
|
||||||
|
|
||||||
// Run the JWTGuard to set the user
|
|
||||||
await super.canActivate(context);
|
|
||||||
const user = request.user as User;
|
|
||||||
|
|
||||||
// Only the creator and reverse share creator can access the reverse share if it's not public
|
|
||||||
if (
|
|
||||||
share.reverseShare &&
|
|
||||||
!share.reverseShare.publicAccess &&
|
|
||||||
share.creatorId !== user?.id &&
|
|
||||||
share.reverseShare.creatorId !== user?.id
|
|
||||||
)
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"Only reverse share creator can access this share",
|
|
||||||
"private_share",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
|
|||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId",
|
"shareId"
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import * as moment from "moment";
|
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { AdminShareDTO } from "./dto/adminShare.dto";
|
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
import { MyShareDTO } from "./dto/myShare.dto";
|
import { MyShareDTO } from "./dto/myShare.dto";
|
||||||
import { ShareDTO } from "./dto/share.dto";
|
import { ShareDTO } from "./dto/share.dto";
|
||||||
@@ -29,25 +25,15 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
|||||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
import { CompletedShareDTO } from "./dto/shareComplete.dto";
|
|
||||||
@Controller("shares")
|
@Controller("shares")
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(private shareService: ShareService) {}
|
||||||
private shareService: ShareService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get("all")
|
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
|
||||||
async getAllShares() {
|
|
||||||
return new AdminShareDTO().fromList(await this.shareService.getShares());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getMyShares(@GetUser() user: User) {
|
async getMyShares(@GetUser() user: User) {
|
||||||
return new MyShareDTO().fromList(
|
return new MyShareDTO().fromList(
|
||||||
await this.shareService.getSharesByUser(user.id),
|
await this.shareService.getSharesByUser(user.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,12 +43,6 @@ export class ShareController {
|
|||||||
return new ShareDTO().from(await this.shareService.get(id));
|
return new ShareDTO().from(await this.shareService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/from-owner")
|
|
||||||
@UseGuards(ShareOwnerGuard)
|
|
||||||
async getFromOwner(@Param("id") id: string) {
|
|
||||||
return new ShareDTO().from(await this.shareService.get(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id/metaData")
|
@Get(":id/metaData")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(ShareSecurityGuard)
|
||||||
async getMetaData(@Param("id") id: string) {
|
async getMetaData(@Param("id") id: string) {
|
||||||
@@ -74,66 +54,46 @@ export class ShareController {
|
|||||||
async create(
|
async create(
|
||||||
@Body() body: CreateShareDTO,
|
@Body() body: CreateShareDTO,
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@GetUser() user: User,
|
@GetUser() user: User
|
||||||
) {
|
) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new ShareDTO().from(
|
return new ShareDTO().from(
|
||||||
await this.shareService.create(body, user, reverse_share_token),
|
await this.shareService.create(body, user, reverse_share_token)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||||
|
async remove(@Param("id") id: string) {
|
||||||
|
await this.shareService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/complete")
|
@Post(":id/complete")
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new CompletedShareDTO().from(
|
return new ShareDTO().from(
|
||||||
await this.shareService.complete(id, reverse_share_token),
|
await this.shareService.complete(id, reverse_share_token)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id/complete")
|
@Throttle(10, 60)
|
||||||
@UseGuards(ShareOwnerGuard)
|
|
||||||
async revertComplete(@Param("id") id: string) {
|
|
||||||
return new ShareDTO().from(await this.shareService.revertComplete(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(":id")
|
|
||||||
@UseGuards(ShareOwnerGuard)
|
|
||||||
async remove(@Param("id") id: string, @GetUser() user: User) {
|
|
||||||
const isDeleterAdmin = user?.isAdmin === true;
|
|
||||||
await this.shareService.remove(id, isDeleterAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throttle({
|
|
||||||
default: {
|
|
||||||
limit: 10,
|
|
||||||
ttl: 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@Get("isShareIdAvailable/:id")
|
@Get("isShareIdAvailable/:id")
|
||||||
async isShareIdAvailable(@Param("id") id: string) {
|
async isShareIdAvailable(@Param("id") id: string) {
|
||||||
return this.shareService.isShareIdAvailable(id);
|
return this.shareService.isShareIdAvailable(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle({
|
@Throttle(20, 5 * 60)
|
||||||
default: {
|
|
||||||
limit: 20,
|
|
||||||
ttl: 5 * 60,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@UseGuards(ShareTokenSecurity)
|
@UseGuards(ShareTokenSecurity)
|
||||||
@Post(":id/token")
|
@Post(":id/token")
|
||||||
async getShareToken(
|
async getShareToken(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Req() request: Request,
|
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() body: SharePasswordDto,
|
@Body() body: SharePasswordDto
|
||||||
) {
|
) {
|
||||||
const token = await this.shareService.getShareToken(id, body.password);
|
const token = await this.shareService.getShareToken(id, body.password);
|
||||||
|
|
||||||
this.clearShareTokenCookies(request, response);
|
|
||||||
response.cookie(`share_${id}_token`, token, {
|
response.cookie(`share_${id}_token`, token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -141,32 +101,4 @@ export class ShareController {
|
|||||||
|
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the 10 most recent share token cookies and deletes the rest and all expired ones
|
|
||||||
*/
|
|
||||||
private clearShareTokenCookies(request: Request, response: Response) {
|
|
||||||
const shareTokenCookies = Object.entries(request.cookies)
|
|
||||||
.filter(([key]) => key.startsWith("share_") && key.endsWith("_token"))
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
payload: this.jwtService.decode(value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const expiredTokens = shareTokenCookies.filter(
|
|
||||||
(cookie) => cookie.payload.exp < moment().unix(),
|
|
||||||
);
|
|
||||||
const validTokens = shareTokenCookies.filter(
|
|
||||||
(cookie) => cookie.payload.exp >= moment().unix(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expiredTokens.forEach((cookie) => response.clearCookie(cookie.key));
|
|
||||||
|
|
||||||
if (validTokens.length > 10) {
|
|
||||||
validTokens
|
|
||||||
.sort((a, b) => a.payload.exp - b.payload.exp)
|
|
||||||
.slice(0, -10)
|
|
||||||
.forEach((cookie) => response.clearCookie(cookie.key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ShareService } from "./share.service";
|
|||||||
imports: [
|
imports: [
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
forwardRef(() => ClamScanModule),
|
ClamScanModule,
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
forwardRef(() => FileModule),
|
forwardRef(() => FileModule),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Share, User } from "@prisma/client";
|
import { Share, User } from "@prisma/client";
|
||||||
import * as archiver from "archiver";
|
import * as archiver from "archiver";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
@@ -16,7 +16,6 @@ import { EmailService } from "src/email/email.service";
|
|||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
|
||||||
import { SHARE_DIRECTORY } from "../constants";
|
import { SHARE_DIRECTORY } from "../constants";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ export class ShareService {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private clamScanService: ClamScanService,
|
private clamScanService: ClamScanService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
||||||
@@ -46,29 +45,25 @@ export class ShareService {
|
|||||||
let expirationDate: Date;
|
let expirationDate: Date;
|
||||||
|
|
||||||
// If share is created by a reverse share token override the expiration date
|
// If share is created by a reverse share token override the expiration date
|
||||||
const reverseShare =
|
const reverseShare = await this.reverseShareService.getByToken(
|
||||||
await this.reverseShareService.getByToken(reverseShareToken);
|
reverseShareToken
|
||||||
|
);
|
||||||
if (reverseShare) {
|
if (reverseShare) {
|
||||||
expirationDate = reverseShare.shareExpiration;
|
expirationDate = reverseShare.shareExpiration;
|
||||||
} else {
|
} else {
|
||||||
const parsedExpiration = parseRelativeDateToAbsolute(share.expiration);
|
// We have to add an exception for "never" (since moment won't like that)
|
||||||
|
if (share.expiration !== "never") {
|
||||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
expirationDate = moment()
|
||||||
|
.add(
|
||||||
if (
|
share.expiration.split("-")[0],
|
||||||
this.config.get("share.maxExpiration") !== 0 &&
|
share.expiration.split(
|
||||||
(expiresNever ||
|
"-"
|
||||||
parsedExpiration >
|
)[1] as moment.unitOfTime.DurationConstructor
|
||||||
moment()
|
)
|
||||||
.add(this.config.get("share.maxExpiration"), "hours")
|
.toDate();
|
||||||
.toDate())
|
} else {
|
||||||
) {
|
expirationDate = moment(0).toDate();
|
||||||
throw new BadRequestException(
|
|
||||||
"Expiration date exceeds maximum expiration date",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationDate = parsedExpiration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||||
@@ -109,7 +104,7 @@ export class ShareService {
|
|||||||
|
|
||||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||||
const archive = archiver("zip", {
|
const archive = archiver("zip", {
|
||||||
zlib: { level: this.config.get("share.zipCompressionLevel") },
|
zlib: { level: 9 },
|
||||||
});
|
});
|
||||||
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
||||||
|
|
||||||
@@ -139,13 +134,13 @@ export class ShareService {
|
|||||||
|
|
||||||
if (share.files.length == 0)
|
if (share.files.length == 0)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You need at least on file in your share to complete it.",
|
"You need at least on file in your share to complete it."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Asynchronously create a zip of all files
|
// Asynchronously create a zip of all files
|
||||||
if (share.files.length > 1)
|
if (share.files.length > 1)
|
||||||
this.createZip(id).then(() =>
|
this.createZip(id).then(() =>
|
||||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
|
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send email for each recipient
|
// Send email for each recipient
|
||||||
@@ -155,19 +150,18 @@ export class ShareService {
|
|||||||
share.id,
|
share.id,
|
||||||
share.creator,
|
share.creator,
|
||||||
share.description,
|
share.description,
|
||||||
share.expiration,
|
share.expiration
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyReverseShareCreator = share.reverseShare
|
if (
|
||||||
? this.config.get("smtp.enabled") &&
|
share.reverseShare &&
|
||||||
|
this.config.get("smtp.enabled") &&
|
||||||
share.reverseShare.sendEmailNotification
|
share.reverseShare.sendEmailNotification
|
||||||
: undefined;
|
) {
|
||||||
|
|
||||||
if (notifyReverseShareCreator) {
|
|
||||||
await this.emailService.sendMailToReverseShareCreator(
|
await this.emailService.sendMailToReverseShareCreator(
|
||||||
share.reverseShare.creator.email,
|
share.reverseShare.creator.email,
|
||||||
share.id,
|
share.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,37 +175,9 @@ export class ShareService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedShare = await this.prisma.share.update({
|
|
||||||
where: { id },
|
|
||||||
data: { uploadLocked: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updatedShare,
|
|
||||||
notifyReverseShareCreator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async revertComplete(id: string) {
|
|
||||||
return this.prisma.share.update({
|
return this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { uploadLocked: false, isZipReady: false },
|
data: { uploadLocked: true },
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShares() {
|
|
||||||
const shares = await this.prisma.share.findMany({
|
|
||||||
orderBy: {
|
|
||||||
expiration: "desc",
|
|
||||||
},
|
|
||||||
include: { files: true, creator: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return shares.map((share) => {
|
|
||||||
return {
|
|
||||||
...share,
|
|
||||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +201,6 @@ export class ShareService {
|
|||||||
return shares.map((share) => {
|
return shares.map((share) => {
|
||||||
return {
|
return {
|
||||||
...share,
|
...share,
|
||||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
|
||||||
recipients: share.recipients.map((recipients) => recipients.email),
|
recipients: share.recipients.map((recipients) => recipients.email),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -245,11 +210,7 @@ export class ShareService {
|
|||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: {
|
files: true,
|
||||||
orderBy: {
|
|
||||||
name: "asc",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
creator: true,
|
creator: true,
|
||||||
security: true,
|
security: true,
|
||||||
},
|
},
|
||||||
@@ -277,14 +238,13 @@ export class ShareService {
|
|||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(shareId: string, isDeleterAdmin = false) {
|
async remove(shareId: string) {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!share) throw new NotFoundException("Share not found");
|
if (!share) throw new NotFoundException("Share not found");
|
||||||
|
if (!share.creatorId)
|
||||||
if (!share.creatorId && !isDeleterAdmin)
|
|
||||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||||
|
|
||||||
await this.fileService.deleteAllFiles(shareId);
|
await this.fileService.deleteAllFiles(shareId);
|
||||||
@@ -325,7 +285,7 @@ export class ShareService {
|
|||||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Maximum views exceeded",
|
"Maximum views exceeded",
|
||||||
"share_max_views_exceeded",
|
"share_max_views_exceeded"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,21 +298,15 @@ export class ShareService {
|
|||||||
const { expiration } = await this.prisma.share.findUnique({
|
const { expiration } = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
});
|
});
|
||||||
|
return this.jwtService.sign(
|
||||||
const tokenPayload = {
|
{
|
||||||
shareId,
|
shareId,
|
||||||
iat: moment().unix(),
|
},
|
||||||
};
|
{
|
||||||
|
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||||
const tokenOptions: JwtSignOptions = {
|
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
};
|
|
||||||
|
|
||||||
if (!moment(expiration).isSame(0)) {
|
|
||||||
tokenOptions.expiresIn = moment(expiration).diff(new Date(), "seconds");
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return this.jwtService.sign(tokenPayload, tokenOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyShareToken(shareId: string, token: string) {
|
async verifyShareToken(shareId: string, token: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PartialType, PickType } from "@nestjs/swagger";
|
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class UpdateOwnUserDTO extends PartialType(
|
export class UpdateOwnUserDTO extends PartialType(
|
||||||
PickType(UserDTO, ["username", "email"] as const),
|
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -16,30 +16,22 @@ export class UserDTO {
|
|||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
hasPassword: boolean;
|
|
||||||
|
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
isLdap: boolean;
|
|
||||||
|
|
||||||
ldapDN?: string;
|
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
totpVerified: boolean;
|
totpVerified: boolean;
|
||||||
|
|
||||||
from(partial: Partial<UserDTO>) {
|
from(partial: Partial<UserDTO>) {
|
||||||
const result = plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||||
result.isLdap = partial.ldapDN?.length > 0;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fromList(partial: Partial<UserDTO>[]) {
|
fromList(partial: Partial<UserDTO>[]) {
|
||||||
return partial.map((part) => this.from(part));
|
return partial.map((part) =>
|
||||||
|
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,18 +27,15 @@ export class UserController {
|
|||||||
// Own user operations
|
// Own user operations
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getCurrentUser(@GetUser() user?: User) {
|
async getCurrentUser(@GetUser() user: User) {
|
||||||
if (!user) return null;
|
return new UserDTO().from(user);
|
||||||
const userDTO = new UserDTO().from(user);
|
|
||||||
userDTO.hasPassword = !!user.password;
|
|
||||||
return userDTO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("me")
|
@Patch("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async updateCurrentUser(
|
async updateCurrentUser(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Body() data: UpdateOwnUserDTO,
|
@Body() data: UpdateOwnUserDTO
|
||||||
) {
|
) {
|
||||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||||
}
|
}
|
||||||
@@ -47,7 +44,7 @@ export class UserController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async deleteCurrentUser(
|
async deleteCurrentUser(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response
|
||||||
) {
|
) {
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
response.cookie("refresh_token", "", {
|
response.cookie("refresh_token", "", {
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { Module } from "@nestjs/common";
|
|||||||
import { EmailModule } from "src/email/email.module";
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { UserController } from "./user.controller";
|
import { UserController } from "./user.controller";
|
||||||
import { UserSevice } from "./user.service";
|
import { UserSevice } from "./user.service";
|
||||||
import { FileModule } from "src/file/file.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [EmailModule, FileModule],
|
imports: [EmailModule],
|
||||||
providers: [UserSevice],
|
providers: [UserSevice],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
exports: [UserSevice]
|
|
||||||
})
|
})
|
||||||
export class UserModule { }
|
export class UserModule {}
|
||||||
|
|||||||
@@ -4,20 +4,15 @@ import * as argon from "argon2";
|
|||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { FileService } from "../file/file.service";
|
|
||||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { ConfigService } from "../config/config.service";
|
|
||||||
import { LdapAuthenticateResult } from "../auth/ldap.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService
|
||||||
private fileService: FileService,
|
) {}
|
||||||
private configService: ConfigService,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.user.findMany();
|
return await this.prisma.user.findMany();
|
||||||
@@ -51,7 +46,7 @@ export class UserSevice {
|
|||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`,
|
`A user with this ${duplicatedField} already exists`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +66,7 @@ export class UserSevice {
|
|||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`,
|
`A user with this ${duplicatedField} already exists`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,53 +74,6 @@ export class UserSevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: { shares: true },
|
|
||||||
});
|
|
||||||
if (!user) throw new BadRequestException("User not found");
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
user.shares.map((share) => this.fileService.deleteAllFiles(share.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return await this.prisma.user.delete({ where: { id } });
|
return await this.prisma.user.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOrCreateFromLDAP(username: string, ldap: LdapAuthenticateResult) {
|
|
||||||
const passwordHash = await argon.hash(crypto.randomUUID());
|
|
||||||
const userEmail = ldap.attributes["userPrincipalName"]?.at(0) ?? `${crypto.randomUUID()}@ldap.local`;
|
|
||||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
|
||||||
const isAdmin = ldap.attributes["memberOf"]?.includes(adminGroup) ?? false;
|
|
||||||
try {
|
|
||||||
return await this.prisma.user.upsert({
|
|
||||||
create: {
|
|
||||||
username,
|
|
||||||
email: userEmail,
|
|
||||||
password: passwordHash,
|
|
||||||
isAdmin,
|
|
||||||
ldapDN: ldap.userDn,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
username,
|
|
||||||
email: userEmail,
|
|
||||||
|
|
||||||
isAdmin,
|
|
||||||
ldapDN: ldap.userDn,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
ldapDN: ldap.userDn
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} 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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import * as moment from "moment";
|
|
||||||
|
|
||||||
export function parseRelativeDateToAbsolute(relativeDate: string) {
|
|
||||||
if (relativeDate == "never") return moment(0).toDate();
|
|
||||||
|
|
||||||
return moment()
|
|
||||||
.add(
|
|
||||||
relativeDate.split("-")[0],
|
|
||||||
relativeDate.split("-")[1] as moment.unitOfTime.DurationConstructor,
|
|
||||||
)
|
|
||||||
.toDate();
|
|
||||||
}
|
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -626,7 +626,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "es2021",
|
"target": "es2017",
|
||||||
"lib": [
|
|
||||||
"ES2021"
|
|
||||||
],
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
files:
|
|
||||||
- source: /frontend/src/i18n/translations/en-US.ts
|
|
||||||
translation: /%original_path%/%locale%.ts
|
|
||||||
pull_request_title: "chore(translations): update translations via Crowdin"
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
clamav:
|
clamav:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
pingvin-share:
|
pingvin-share:
|
||||||
image: stonith404/pingvin-share
|
image: stonith404/pingvin-share
|
||||||
|
|||||||
95
docs/CONTRIBUTING.es.md
Normal file
95
docs/CONTRIBUTING.es.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
_Leer esto en otro idioma: [Inglés](/CONTRIBUTING.md), [Español](/docs/CONTRIBUTING.es.md), [Chino Simplificado](/docs/CONTRIBUTING.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contribuyendo
|
||||||
|
|
||||||
|
¡Nos ❤️ encantaría que contribuyas a Pingvin Share y nos ayudes a hacerlo mejor! Todas las contribuciones son bienvenidas, incluyendo problemas, sugerencias, _pull requests_ y más.
|
||||||
|
|
||||||
|
## Para comenzar
|
||||||
|
|
||||||
|
Si encontraste un error, tienes una sugerencia o algo más, simplemente crea un problema (issue) en GitHub y nos pondremos en contacto contigo 😊.
|
||||||
|
|
||||||
|
## Para hacer una Pull Request
|
||||||
|
|
||||||
|
Antes de enviar la pull request para su revisión, asegúrate de que:
|
||||||
|
|
||||||
|
- El nombre de la pull request sigue las [especificaciones de Commits Convencionales](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
`<tipo>[ámbito opcional]: <descripción>`
|
||||||
|
|
||||||
|
ejemplo:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): agregar protección con contraseña
|
||||||
|
```
|
||||||
|
|
||||||
|
Donde `tipo` puede ser:
|
||||||
|
|
||||||
|
- **feat** - es una nueva función
|
||||||
|
- **doc** - cambios solo en la documentación
|
||||||
|
- **fix** - una corrección de error
|
||||||
|
- **refactor** - cambios en el código que no solucionan un error ni agregan una función
|
||||||
|
|
||||||
|
- Tu pull requests tiene una descripción detallada.
|
||||||
|
|
||||||
|
- Ejecutaste `npm run format` para formatear el código.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>¿No sabes como crear una pull request? Aprende cómo crear una pull request</summary>
|
||||||
|
|
||||||
|
1. Crea un fork del repositorio haciendo clic en el botón `Fork` en el repositorio de Pingvin Share.
|
||||||
|
|
||||||
|
2. Clona tu fork en tu máquina con `git clone`.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/[your_username]/pingvin-share
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Trabajar - hacer commit - repetir
|
||||||
|
|
||||||
|
4. Haz un `push` de tus cambios a GitHub.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git push origin [nombre_de_tu_nueva_rama]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Envía tus cambios para su revisión. Si vas a tu repositorio en GitHub, verás un botón `Comparar y crear pull requests`. Haz clic en ese botón.
|
||||||
|
6. Inicia una Pull Request
|
||||||
|
7. Ahora envía la pull requests y haz clic en `Crear pull requests`
|
||||||
|
8. Espera a que alguien revise tu solicitud y apruebe o rechace tus cambios. Puedes ver los comentarios en la página de la solicitud en GitHub.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Instalación del proyecto
|
||||||
|
|
||||||
|
Pingvin Share consiste de un frontend y un backend.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
El backend está hecho con [Nest.js](https://nestjs.com) y usa Typescript.
|
||||||
|
|
||||||
|
#### Instalación
|
||||||
|
|
||||||
|
1. Abrimos la carpeta `backend`
|
||||||
|
2. Instalamos las dependencias con `npm install`
|
||||||
|
3. Haz un `push` del esquema de la base de datos a la base de datos ejecutando `npx prisma db push`
|
||||||
|
4. Rellena la base de datos ejecutando `npx prisma db seed`
|
||||||
|
5. Inicia el backend con `npm run dev`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
El frontend está hecho con [Next.js](https://nextjs.org) y usa Typescript.
|
||||||
|
|
||||||
|
#### Instalación
|
||||||
|
|
||||||
|
1. Primero inicia el backend
|
||||||
|
2. Abre la carpeta `frontend`
|
||||||
|
3. Instala las dependencias con `npm install`
|
||||||
|
4. Inicia el frontend con `npm run dev`
|
||||||
|
|
||||||
|
¡Ya está todo listo!
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Por el momento, solo tenemos pruebas para el backend. Para ejecutar estas pruebas, debes ejecutar el comando `npm run test:system` en la carpeta del backend.
|
||||||
97
docs/CONTRIBUTING.zh-cn.md
Normal file
97
docs/CONTRIBUTING.zh-cn.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
_选择合适的语言阅读: [西班牙语](/docs/CONTRIBUTING.es.md), [英语](/CONTRIBUTING.md), [简体中文](/docs/CONTRIBUTING.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 提交贡献
|
||||||
|
|
||||||
|
我们非常感谢你 ❤️ 为 Pingvin Share 提交贡献使其变得更棒! 欢迎任何形式的贡献,包括 issues, 建议, PRs 和其他形式
|
||||||
|
|
||||||
|
## 小小的开始
|
||||||
|
|
||||||
|
你找到了一个 bug,有新特性建议或者其他提议,请在 GitHub 建立一个 issue 以便我和你联络 😊
|
||||||
|
|
||||||
|
## 提交一个 Pull Request
|
||||||
|
|
||||||
|
在你提交 PR 前请确保
|
||||||
|
|
||||||
|
- PR 的名字遵守 [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||||
|
|
||||||
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): add password protection
|
||||||
|
```
|
||||||
|
|
||||||
|
`TYPE` 可以是:
|
||||||
|
|
||||||
|
- **feat** - 这是一个新特性 feature
|
||||||
|
- **doc** - 仅仅改变了文档部分 documentation
|
||||||
|
- **fix** - 修复了一个 bug
|
||||||
|
- **refactor** - 更新了代码,但是并非出于增加新特性 feature 或修复 bug 的目的
|
||||||
|
|
||||||
|
- 请在 PR 中附详细的解释说明
|
||||||
|
- 使用 `npm run format` 格式化你的代码
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>不知道怎么发起一个 PR? 点开了解怎么发起一个 PR </summary>
|
||||||
|
|
||||||
|
1. 点击 Pingvin Share 仓库的 `Fork` 按钮,复制一份你的仓库
|
||||||
|
|
||||||
|
2. 通过 `git clone` 将你的仓库克隆到本地
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/[你的用户名]/pingvin-share
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 进行你的修改 - 提交 commit 你的修改 - 重复直到完成
|
||||||
|
|
||||||
|
4. 将你的修改提交到 GitHub
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git push origin [你的新分支的名字]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 提交你的代码以便代码审查
|
||||||
|
|
||||||
|
如果你进入你 fork 的 Github 仓库,你会看到一个 `Compare & pull request` 按钮,点击该按钮
|
||||||
|
|
||||||
|
6. 发起一个 PR
|
||||||
|
7. 点击 `Create pull request` 来提交你的 PR
|
||||||
|
8. 等待代码审查,通过或以某些原因拒绝
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 配置开发项目
|
||||||
|
|
||||||
|
Pingvin Share 包括前端和后端部分
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
后端使用 [Nest.js](https://nestjs.com) 建立,使用 Typescript
|
||||||
|
|
||||||
|
#### 搭建
|
||||||
|
|
||||||
|
1. 打开 `backend` 文件夹
|
||||||
|
2. 使用 `npm install` 安装依赖
|
||||||
|
3. 通过 `npx prisma db push` 配置数据库结构
|
||||||
|
4. 通过 `npx prisma db seed` 初始化数据库数据
|
||||||
|
5. 通过 `npm run dev` 启动后端
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
后端使用 [Next.js](https://nextjs.org) 建立,使用 Typescript
|
||||||
|
|
||||||
|
#### 搭建
|
||||||
|
|
||||||
|
1. 首先启动后端
|
||||||
|
2. 打开 `frontend` 文件夹
|
||||||
|
3. 通过 `npm install` 安装依赖
|
||||||
|
4. 通过 `npm run dev` 启动前端
|
||||||
|
|
||||||
|
开发项目配置完成
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
目前阶段我们只有后端的系统测试,在 `backend` 文件夹运行 `npm run test:system` 来执行系统测试
|
||||||
128
docs/README.es.md
Normal file
128
docs/README.es.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alternativa a WeTransfer.
|
||||||
|
|
||||||
|
## ✨ Características
|
||||||
|
|
||||||
|
- Compartir archivos utilizando un enlace
|
||||||
|
- Tamaño de archivo ilimitado (unicamente restringido por el espacio en disco)
|
||||||
|
- Establecer una fecha de caducidad para los recursos compartidos
|
||||||
|
- Uso compartido seguro con límites de visitantes y contraseñas
|
||||||
|
- Destinatarios de correo electrónico
|
||||||
|
- Integración con ClamAV para escaneos de seguridad
|
||||||
|
|
||||||
|
## 🐧 Conoce Pingvin Share
|
||||||
|
|
||||||
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [Reseña por DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
|
|
||||||
|
## ⌨️ Instalación
|
||||||
|
|
||||||
|
> Nota: Pingvin Share está en sus primeras etapas y puede contener errores.
|
||||||
|
|
||||||
|
### Instalación con Docker (recomendada)
|
||||||
|
|
||||||
|
1. Descarge el archivo `docker-compose.yml`
|
||||||
|
2. Ejecute `docker-compose up -d`
|
||||||
|
|
||||||
|
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Instalación autónoma
|
||||||
|
|
||||||
|
Herramientas requeridas:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) para ejecutar Pingvin Share en segundo plano
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Consultar la última versión
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Iniciar el backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Iniciar el frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Integraciones
|
||||||
|
|
||||||
|
#### ClamAV (Unicamente con Docker)
|
||||||
|
|
||||||
|
ClamAV se utiliza para escanear los recursos compartidos en busca de archivos maliciosos y eliminarlos si los encuentra.
|
||||||
|
|
||||||
|
1. Añade el contenedor ClamAV al stack de Docker Compose (ver `docker-compose.yml`) e inicie el contenedor.
|
||||||
|
2. Docker esperará a que ClamAV se inicie antes de iniciar Pingvin Share. Esto puede tardar uno o dos minutos.
|
||||||
|
3. Los registros de Pingvin Share ahora deberían decir "ClamAV está activo".
|
||||||
|
|
||||||
|
Por favor, ten en cuenta que ClamAV necesita muchos [recursos](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
|
### Recursos adicionales
|
||||||
|
|
||||||
|
- [Instalación en Synology NAS (Inglés)](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
|
### Actualizar a una nueva versión
|
||||||
|
|
||||||
|
Dado que Pingvin Share se encuentra en una fase inicial, consulte las notas de la versión para conocer los cambios de última hora antes de actualizar.
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Instalación autónoma
|
||||||
|
|
||||||
|
1. Deten la aplicación en ejecución
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Repite los pasos de la [guía de instalación](#instalación-autonoma) excepto el paso de `git clone`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Consultar la última versión
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Iniciar el backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# Iniciar frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marca personalizada
|
||||||
|
|
||||||
|
Puedes cambiar el nombre y el logotipo de la aplicación visitando la página de configuración de administrador.
|
||||||
|
|
||||||
|
## 🖤 Contribuye
|
||||||
|
|
||||||
|
¡Eres bienvenido a contribuir a Pingvin Share! Sige la [guía de contribución](/CONTRIBUTING.md) para empezar.
|
||||||
126
docs/README.zh-cn.md
Normal file
126
docs/README.zh-cn.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pingvin Share 是一个可自建的文件分享平台,是 WeTransfer 的一个替代品
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 通过可自定义后缀的链接分享文件
|
||||||
|
- 可自定义任意大小的文件上传限制 (受制于托管所在的硬盘大小)
|
||||||
|
- 对共享链接设置有效期限
|
||||||
|
- 对共享链接设置访问次数和访问密码
|
||||||
|
- 通过邮件自动发送共享链接
|
||||||
|
- 整合 ClamAV 进行反病毒检查
|
||||||
|
|
||||||
|
## 🐧 了解一下 Pingvin Share
|
||||||
|
|
||||||
|
- [示例网站](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [DB Tech 推荐视频](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
|
|
||||||
|
## ⌨️ 自建指南
|
||||||
|
|
||||||
|
> 注意:Pingvin Share 仍处于开发阶段并且可能存在 bugs
|
||||||
|
|
||||||
|
### Docker 部署 (推荐)
|
||||||
|
|
||||||
|
1. 下载 `docker-compose.yml`
|
||||||
|
2. 运行命令 `docker-compose up -d`
|
||||||
|
|
||||||
|
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Stand-alone 部署
|
||||||
|
|
||||||
|
必须的依赖:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) 用于后台运行 Pingvin Share
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# 获取最新的版本
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# 启动后端 backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# 启动前端 frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### 整合组件
|
||||||
|
|
||||||
|
#### ClamAV (仅限 Docker 部署)
|
||||||
|
|
||||||
|
扫描上传文件中是否存在可疑文件,如果存在 ClamAV 会自动移除
|
||||||
|
|
||||||
|
1. 在 docker-compose 配置中添加 ClamAV 容器 (见 `docker-compose.yml` 注释部分) 并启动容器
|
||||||
|
2. Docker 会在启动 Pingvin Share 前启动 ClamAV,也许会花费 1-2 分钟
|
||||||
|
3. Pingvin Share 日志中应该有 "ClamAV is active"
|
||||||
|
|
||||||
|
请注意 ClamAV 会消耗很多 [系统资源(特别是内存)](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)
|
||||||
|
|
||||||
|
### 更多资源
|
||||||
|
|
||||||
|
- [群晖 NAS 配置](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
|
### 升级
|
||||||
|
|
||||||
|
因为 Pingvin Share 仍处在开发阶段,在升级前请务必阅读 release notes 避免不可逆的改变
|
||||||
|
|
||||||
|
#### Docker 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stand-alone 升级
|
||||||
|
|
||||||
|
1. 停止正在运行的 app
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
2. 重复 [installation guide](#stand-alone-installation) 中的步骤,除了 `git clone` 这一步
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# 获取最新的版本
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# 启动后端 backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# 启动前端 frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义品牌
|
||||||
|
|
||||||
|
你可以在管理员配置页面改变网站的名字和 logo
|
||||||
|
|
||||||
|
## 🖤 提交贡献
|
||||||
|
|
||||||
|
非常欢迎向 Pingvin Share 提交贡献! 请阅读 [contribution guide](/CONTRIBUTING.md) 来提交你的贡献
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Before you submit the pull request for review please ensure that
|
|
||||||
|
|
||||||
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
|
||||||
|
|
||||||
`<type>[optional scope]: <description>`
|
|
||||||
|
|
||||||
example:
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(share): add password protection
|
|
||||||
```
|
|
||||||
|
|
||||||
When `TYPE` can be:
|
|
||||||
|
|
||||||
- **feat** - is a new feature
|
|
||||||
- **doc** - documentation only changes
|
|
||||||
- **fix** - a bug fix
|
|
||||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
|
||||||
|
|
||||||
- Your pull request has a detailed description
|
|
||||||
- You run `npm run format` to format the code
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
|
|
||||||
|
|
||||||
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
|
|
||||||
|
|
||||||
2. Clone your fork to your machine with `git clone`
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git clone https://github.com/[your_username]/pingvin-share
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Work - commit - repeat
|
|
||||||
|
|
||||||
4. Push changes to GitHub
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git push origin [name_of_your_new_branch]
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Submit your changes for review
|
|
||||||
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
|
||||||
6. Start a Pull Request
|
|
||||||
7. Now submit the pull request and click on `Create pull request`.
|
|
||||||
8. Get a code review approval/reject
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Setup project
|
|
||||||
|
|
||||||
Pingvin Share consists of a frontend and a backend.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
|
||||||
|
|
||||||
#### Setup
|
|
||||||
|
|
||||||
1. Open the `backend` folder
|
|
||||||
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. Install the dependencies with `npm install`
|
|
||||||
4. Start the frontend with `npm run dev`
|
|
||||||
|
|
||||||
You're all set!
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
At the moment we only have system tests for the backend. To run these tests, run `npm run test:system` in the backend folder.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Translating
|
|
||||||
|
|
||||||
You can help to translate Pingvin Share into your language.
|
|
||||||
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
|
|
||||||
|
|
||||||
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
|
|
||||||
|
|
||||||
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
id: introduction
|
|
||||||
---
|
|
||||||
|
|
||||||
# Introduction
|
|
||||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Share files using a link
|
|
||||||
- Unlimited file size (restricted only by disk space)
|
|
||||||
- Set an expiration date for shares
|
|
||||||
- Secure shares with visitor limits and passwords
|
|
||||||
- Email recipients
|
|
||||||
- Integration with ClamAV for security scans
|
|
||||||
|
|
||||||
And more!
|
|
||||||
|
|
||||||
## 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/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
id: configuration
|
|
||||||
---
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
|
|
||||||
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
|
|
||||||
|
|
||||||
#### Environment variables
|
|
||||||
|
|
||||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
|
||||||
|
|
||||||
##### Backend
|
|
||||||
|
|
||||||
| Variable | Default Value | Description |
|
|
||||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
|
||||||
| `PORT` | `8080` | The port on which the backend listens. |
|
|
||||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
|
||||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
|
||||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
|
||||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
|
||||||
|
|
||||||
##### Frontend
|
|
||||||
|
|
||||||
| Variable | Default Value | Description |
|
|
||||||
| --------- | ----------------------- | ---------------------------------------- |
|
|
||||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
|
||||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user