Compare commits
15 Commits
v1.5.0
...
link-copy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fa76563e | ||
|
|
f96ac5e4ba | ||
|
|
bbbf10d233 | ||
|
|
e9d1a9abb6 | ||
|
|
8fdba0ca7c | ||
|
|
e5718700bc | ||
|
|
e40a0c844c | ||
|
|
e647746c93 | ||
|
|
9be77826e9 | ||
|
|
a9bb05c4da | ||
|
|
e1a9f2a27c | ||
|
|
ba62c13cfa | ||
|
|
3de744d5e9 | ||
|
|
61608cfe2d | ||
|
|
db755ef300 |
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -31,13 +31,14 @@ body:
|
||||
label: "👎 Actual Behavior"
|
||||
description: "What did actually happen? Add screenshots, if applicable."
|
||||
placeholder: "It actually ..."
|
||||
- type: textarea
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: "📜 Logs"
|
||||
description: "Paste any relevant logs here."
|
||||
label: "🌐 Browser"
|
||||
description: "Which browser do you use?"
|
||||
placeholder: "Firefox"
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
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:
|
||||
release:
|
||||
@@ -10,25 +10,15 @@ jobs:
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
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
|
||||
- name: login to docker registry
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag stonith404/pingvin-share:latest \
|
||||
--tag stonith404/pingvin-share:${{ github.ref_name }} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
|
||||
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 }}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,9 +7,6 @@ node_modules
|
||||
/frontend/.next/
|
||||
/frontend/out/
|
||||
|
||||
# yarn
|
||||
yarn.lock
|
||||
|
||||
# build
|
||||
build/
|
||||
dist/
|
||||
@@ -41,9 +38,6 @@ yarn-error.log*
|
||||
# project specific
|
||||
/backend/data/
|
||||
/data/
|
||||
/docs/build/
|
||||
/docs/.docusaurus
|
||||
/docs/.cache-loader
|
||||
|
||||
# Jetbrains specific (webstorm)
|
||||
.idea/**/**
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/backend/src/constants.ts
|
||||
523
CHANGELOG.md
523
CHANGELOG.md
@@ -1,526 +1,3 @@
|
||||
## [1.5.0](https://github.com/stonith404/pingvin-share/compare/v1.4.0...v1.5.0) (2024-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **share:** add share ID length setting ([#677](https://github.com/stonith404/pingvin-share/issues/677)) ([9d4bb55](https://github.com/stonith404/pingvin-share/commit/9d4bb55a0945450f8a42c212d7f23983db38f37f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* totp can't be enabled if user is a ldap user ([c8f05f2](https://github.com/stonith404/pingvin-share/commit/c8f05f2475a5a54550cf64ef57c8b612580273be))
|
||||
|
||||
## [1.4.0](https://github.com/stonith404/pingvin-share/compare/v1.3.0...v1.4.0) (2024-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add "creatorEmail" config bariable to share recipient email message ([c7dacb2](https://github.com/stonith404/pingvin-share/commit/c7dacb26e87504a1c5e6b0d87cdcd5ed91b9cdf5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remote arbitrary file overwrite on file upload endpoint ([6cf5c66](https://github.com/stonith404/pingvin-share/commit/6cf5c66fe2eda1e0a525edf7440d047fe2f0e35b))
|
||||
|
||||
## [1.3.0](https://github.com/stonith404/pingvin-share/compare/v1.2.4...v1.3.0) (2024-11-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'secureCookies' configuration variable to explicitly set the secure flag and prevent confusion ([4ce6420](https://github.com/stonith404/pingvin-share/commit/4ce64206be7440a99299e1ed238ced7408c0563d))
|
||||
* add confirm dialog for leaving the page if an upload is in progress ([d8084e4](https://github.com/stonith404/pingvin-share/commit/d8084e401d7572b2d6e38ffa20cb678a0fb0e615))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve share completed dialog redirection for reverse shares ([4ef7ebb](https://github.com/stonith404/pingvin-share/commit/4ef7ebb0622f16d2d2c4d114b5fc15298e2ba24f))
|
||||
* prevent deletion of last admin account ([e1a5d19](https://github.com/stonith404/pingvin-share/commit/e1a5d195448e3d741b77fb982ce515489a360562))
|
||||
* throw error if no disk space is left ([c26de4e](https://github.com/stonith404/pingvin-share/commit/c26de4e881edfe6c7db617c0aeba89871397ebe2))
|
||||
* use current window url instead of app url in frontend ([6f45c3b](https://github.com/stonith404/pingvin-share/commit/6f45c3b1fbf4a95b29e5742878b55a1afa0b8886))
|
||||
|
||||
## [1.2.4](https://github.com/stonith404/pingvin-share/compare/v1.2.3...v1.2.4) (2024-10-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't enforce password lenght for sign in form because of LDAP ([428c1d2](https://github.com/stonith404/pingvin-share/commit/428c1d2b993a05a25cc94aabe56216b9ab969fa1))
|
||||
* use app name as totp issuer ([c89ca7e](https://github.com/stonith404/pingvin-share/commit/c89ca7e64b08f437dd1b7e9bf2b9d674cc612228))
|
||||
|
||||
## [1.2.3](https://github.com/stonith404/pingvin-share/compare/v1.2.2...v1.2.3) (2024-10-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share password can be bypassed if a deleted share with the same id was visited before ([acbff6e](https://github.com/stonith404/pingvin-share/commit/acbff6e129d236452180f8b96775457d135ac080))
|
||||
|
||||
## [1.2.2](https://github.com/stonith404/pingvin-share/compare/v1.2.1...v1.2.2) (2024-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** change general config icon to gear ([#649](https://github.com/stonith404/pingvin-share/issues/649)) ([958b79d](https://github.com/stonith404/pingvin-share/commit/958b79d787585c367a693872fd105a326e6e8d38))
|
||||
* environment variable `API_URL` can't be changed ([fe085b5](https://github.com/stonith404/pingvin-share/commit/fe085b58a5f3c0152df12957aa150c0876c2a074))
|
||||
|
||||
## [1.2.1](https://github.com/stonith404/pingvin-share/compare/v1.2.0...v1.2.1) (2024-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disallow passwort reset if it's a ldap user ([2e69224](https://github.com/stonith404/pingvin-share/commit/2e692241c57b001c9312302523c6374c0c24ea0c))
|
||||
* error message for invalid max use count of reverse share ([613bae9](https://github.com/stonith404/pingvin-share/commit/613bae90330a76c0964352a3fe927df3697309eb))
|
||||
* **oauth:** add `post_logout_redirect_uri` to OAuth logout redirect URI ([#638](https://github.com/stonith404/pingvin-share/issues/638)) ([bfbe8de](https://github.com/stonith404/pingvin-share/commit/bfbe8de98a6a7a2d32dd8d4dddbcc1d4ce6388f4))
|
||||
* share can't be created if an invalid email is entered in mail recipients ([d5cd300](https://github.com/stonith404/pingvin-share/commit/d5cd3002a1661e58d584e12280be36f17948c38c))
|
||||
* trim username, email and password on sign in and sign up page ([77a092a](https://github.com/stonith404/pingvin-share/commit/77a092a3cf089a4aa8b9897b5ad14e5500181d10))
|
||||
|
||||
## [1.2.0](https://github.com/stonith404/pingvin-share/compare/v1.1.3...v1.2.0) (2024-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** add ability to limit user IDs for Discord authentication ([#621](https://github.com/stonith404/pingvin-share/issues/621)) ([5883dff](https://github.com/stonith404/pingvin-share/commit/5883dff4cf0abe99b3ac8f0b56fdc9d04e80b51c))
|
||||
* **oauth:** Add option to logout from OpenID Connect provider ([2b3ce3f](https://github.com/stonith404/pingvin-share/commit/2b3ce3ffd250f7e3052d43c1c1e76947abf91e55)), closes [#598](https://github.com/stonith404/pingvin-share/issues/598)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use unique port env variable for backend ([d6b8b56](https://github.com/stonith404/pingvin-share/commit/d6b8b56247814087c2b676fe2367300172b5a94b))
|
||||
|
||||
## [1.1.3](https://github.com/stonith404/pingvin-share/compare/v1.1.2...v1.1.3) (2024-09-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve the LDAP implementation ([#615](https://github.com/stonith404/pingvin-share/issues/615)) ([3310fe5](https://github.com/stonith404/pingvin-share/commit/3310fe53b3e4c89db78d57ede6c8d57d8137ecc1)), closes [#601](https://github.com/stonith404/pingvin-share/issues/601)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* omit invalid username characters in oidc registration ([adc4af9](https://github.com/stonith404/pingvin-share/commit/adc4af996d30b295b06e4ee517aa53be62c0f6c1))
|
||||
|
||||
## [1.1.2](https://github.com/stonith404/pingvin-share/compare/v1.1.1...v1.1.2) (2024-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable auto complete for email recipients and share password ([ee73293](https://github.com/stonith404/pingvin-share/commit/ee73293c0f822d3e79cfefd096c656d4c36a12d1))
|
||||
* enable secure cookies if app url starts with https ([69752b8](https://github.com/stonith404/pingvin-share/commit/69752b8b417edda1ab4a4acedbdda09d545d6df8))
|
||||
|
||||
## [1.1.1](https://github.com/stonith404/pingvin-share/compare/v1.1.0...v1.1.1) (2024-09-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add environment variable to trust the reverse proxy ([b13a81a](https://github.com/stonith404/pingvin-share/commit/b13a81a88ca871c5714b2ed52d0e12fb7ceca176))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable email login if ldap is enabled ([d9cfe69](https://github.com/stonith404/pingvin-share/commit/d9cfe697d66e9db7bfbc2252b3700580793ce9bb))
|
||||
|
||||
## [1.1.0](https://github.com/stonith404/pingvin-share/compare/v1.0.4...v1.1.0) (2024-09-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow smpt without username and password ([8b3e28b](https://github.com/stonith404/pingvin-share/commit/8b3e28bac83e5326234096445395046ebdb0c4d7))
|
||||
* auto redirect to oauth provider ([7dc2e56](https://github.com/stonith404/pingvin-share/commit/7dc2e56fee1afc1078774cc702c0f1fee9bae938))
|
||||
|
||||
## [1.0.4](https://github.com/stonith404/pingvin-share/compare/v1.0.3...v1.0.4) (2024-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* oauth2 login can fail in some cases because the user can't be found ([92e1e82](https://github.com/stonith404/pingvin-share/commit/92e1e82e095075edf04019887f9c2048c21d00d6))
|
||||
|
||||
## [1.0.3](https://github.com/stonith404/pingvin-share/compare/v1.0.2...v1.0.3) (2024-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve oidc error logging ([dee7098](https://github.com/stonith404/pingvin-share/commit/dee70987eb74eda4a9ab7332522fa5540cee9761))
|
||||
|
||||
## [1.0.2](https://github.com/stonith404/pingvin-share/compare/v1.0.1...v1.0.2) (2024-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default logo not displayed on fresh installations ([3e0735c](https://github.com/stonith404/pingvin-share/commit/3e0735c62079ac777fd08051b7e7602eebf74a5d))
|
||||
|
||||
## [1.0.1](https://github.com/stonith404/pingvin-share/compare/v1.0.0...v1.0.1) (2024-08-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **email:** add {email} placeholder to user invitation email ([#564](https://github.com/stonith404/pingvin-share/issues/564)) ([8c5c696](https://github.com/stonith404/pingvin-share/commit/8c5c696c514a5fb450462184240b21553d7f1532))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **translations:** add missing string for ldap group ([64efac5](https://github.com/stonith404/pingvin-share/commit/64efac5b685bf2de9d65c6a4f8890d45afe6476d))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
36
Dockerfile
36
Dockerfile
@@ -1,38 +1,37 @@
|
||||
# Stage 1: Frontend dependencies
|
||||
FROM node:20-alpine AS frontend-dependencies
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:19-slim AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
# Stage 2: on frontend change
|
||||
FROM node:19-slim AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
RUN apk add --no-cache python3
|
||||
# Stage 3: on backend dependency change
|
||||
FROM node:19-slim AS backend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4: Build backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
# Stage 4:on backend change
|
||||
FROM node:19-slim AS backend-builder
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:19-slim AS runner
|
||||
ENV NODE_ENV=docker
|
||||
|
||||
RUN apk update --no-cache \
|
||||
&& apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl caddy
|
||||
RUN apt-get update && apt-get install -y curl openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
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/package.json ./
|
||||
|
||||
COPY ./reverse-proxy /etc/caddy
|
||||
COPY ./scripts/docker-entrypoint.sh /opt/app/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
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
|
||||
130
README.md
130
README.md
@@ -1,10 +1,12 @@
|
||||
# <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)
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is a self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -13,8 +15,6 @@ Pingvin Share is a self-hosted file sharing platform and an alternative for WeTr
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Reverse shares
|
||||
- OIDC and LDAP authentication
|
||||
- Integration with ClamAV for security scans
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
@@ -26,26 +26,122 @@ Pingvin Share is a self-hosted file sharing platform and an alternative for WeTr
|
||||
|
||||
## ⌨️ Setup
|
||||
|
||||
> Note: Pingvin Share is in its early stages and may contain bugs.
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
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 🐧!
|
||||
|
||||
> [!TIP]
|
||||
> Checkout [Pocket ID](https://github.com/stonith404/pocket-id), a user-friendly OIDC provider that lets you easily log in to services like Pingvin Share using Passkeys.
|
||||
### Stand-alone Installation
|
||||
|
||||
## 📚 Documentation
|
||||
Required tools:
|
||||
|
||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||
- [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
|
||||
|
||||
We would love it if you want to help make Pingvin Share better! You can either [help to translate](https://stonith404.github.io/pingvin-share/help-out/translate) Pingvin Share or [contribute to the codebase](https://stonith404.github.io/pingvin-share/help-out/contribute).
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
Thank you for supporting Pingvin Share 🙏
|
||||
|
||||
- [@COMPLEXWASTAKEN](https://github.com/COMPLEXWASTAKEN)
|
||||
You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Older versions of Pingvin Share do not receive security updates. To ensure your system remains secure, we strongly recommend updating Pingvin Share regularly. You can automate these updates using tools like [Watchtower](https://github.com/containrrr/watchtower).
|
||||
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/src/constants.ts
|
||||
12064
backend/package-lock.json
generated
12064
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,87 +1,80 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "0.15.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"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"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.4.3",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.3",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.3",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.41.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"cache-manager": "^5.7.6",
|
||||
"clamscan": "^2.3.1",
|
||||
"@nestjs/common": "^9.3.9",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
"@nestjs/core": "^9.3.9",
|
||||
"@nestjs/jwt": "^10.0.2",
|
||||
"@nestjs/passport": "^9.0.3",
|
||||
"@nestjs/platform-express": "^9.3.9",
|
||||
"@nestjs/schedule": "^2.2.0",
|
||||
"@nestjs/swagger": "^6.2.1",
|
||||
"@nestjs/throttler": "^4.0.0",
|
||||
"@prisma/client": "^4.11.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"clamscan": "^2.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"jmespath": "^0.16.0",
|
||||
"ldapts": "^7.2.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.15",
|
||||
"moment": "^2.29.4",
|
||||
"nodemailer": "^6.9.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"ts-node": "^10.9.2"
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^4.4.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.31.3",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.3",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/clamscan": "^2.0.8",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cron": "^2.4.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode-svg": "^1.1.5",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.6.0",
|
||||
"@typescript-eslint/parser": "^8.6.0",
|
||||
"@nestjs/cli": "^9.2.0",
|
||||
"@nestjs/schematics": "^9.0.4",
|
||||
"@nestjs/testing": "^9.3.9",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/clamscan": "^2.0.4",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.15.0",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
||||
"@typescript-eslint/parser": "^5.54.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"newman": "^6.2.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^5.19.1",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.8.4",
|
||||
"prisma": "^4.11.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.6.2",
|
||||
"wait-on": "^8.0.1"
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "^4.9.5",
|
||||
"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");
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
||||
@@ -14,9 +14,8 @@ model User {
|
||||
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String?
|
||||
password String
|
||||
isAdmin Boolean @default(false)
|
||||
ldapDN String? @unique
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
@@ -27,8 +26,6 @@ model User {
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
|
||||
oAuthUsers OAuthUser[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -40,8 +37,6 @@ model RefreshToken {
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
oauthIDToken String? // prefixed with the ID of the issuing OAuth provider, separated by a colon
|
||||
}
|
||||
|
||||
model LoginToken {
|
||||
@@ -65,20 +60,10 @@ model ResetPasswordToken {
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
name String?
|
||||
uploadLocked Boolean @default(false)
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
@@ -106,8 +91,6 @@ model ReverseShare {
|
||||
maxShareSize String
|
||||
sendEmailNotification Boolean
|
||||
remainingUses Int
|
||||
simplified Boolean @default(false)
|
||||
publicAccess Boolean @default(true)
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
@@ -151,8 +134,9 @@ model Config {
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
description String
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
|
||||
@@ -1,314 +1,150 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables: ConfigVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
general: {
|
||||
appName: {
|
||||
description: "Name of the application",
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share",
|
||||
secret: false,
|
||||
},
|
||||
appUrl: {
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
defaultValue: "http://localhost:3000",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
secureCookies: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
showHomePage: {
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
sessionDuration: {
|
||||
type: "number",
|
||||
defaultValue: "2160",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
share: {
|
||||
allowRegistration: {
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthenticatedShares: {
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
maxExpiration: {
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
secret: false,
|
||||
},
|
||||
shareIdLength: {
|
||||
type: "number",
|
||||
defaultValue: "8",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
zipCompressionLevel: {
|
||||
type: "number",
|
||||
defaultValue: "9",
|
||||
},
|
||||
chunkSize: {
|
||||
type: "number",
|
||||
defaultValue: "10000000",
|
||||
secret: false,
|
||||
},
|
||||
autoOpenShareModal: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
defaultValue: "1073741824",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
description:
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
shareRecipientsSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
defaultValue: "Files shared with you",
|
||||
},
|
||||
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",
|
||||
defaultValue:
|
||||
"Hey!\n\n{creator} ({creatorEmail}) 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: {
|
||||
description:
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
type: "string",
|
||||
defaultValue: "Reverse share link used",
|
||||
},
|
||||
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",
|
||||
defaultValue:
|
||||
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
resetPasswordSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share password reset",
|
||||
},
|
||||
resetPasswordMessage: {
|
||||
description:
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
type: "text",
|
||||
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 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when an admin invites an user.",
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share invite",
|
||||
},
|
||||
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",
|
||||
defaultValue:
|
||||
'Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYou can use the email "{email}" and the password "{password}" to sign in.\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 🐧",
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
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",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthorizedCertificates: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
host: {
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
port: {
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
email: {
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
username: {
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
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: ""
|
||||
},
|
||||
|
||||
fieldNameMemberOf: {
|
||||
type: "string",
|
||||
defaultValue: "memberOf",
|
||||
},
|
||||
fieldNameEmail: {
|
||||
type: "string",
|
||||
defaultValue: "userPrincipalName",
|
||||
}
|
||||
},
|
||||
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-limitedUsers": {
|
||||
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-signOut": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"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": {
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
@@ -365,15 +201,12 @@ async function seedConfigVariables() {
|
||||
|
||||
async function migrateConfigVariables() {
|
||||
const existingConfigVariables = await prisma.config.findMany();
|
||||
const orderMap: { [category: string]: number } = {};
|
||||
|
||||
for (const existingConfigVariable of existingConfigVariables) {
|
||||
const configVariable =
|
||||
configVariables[existingConfigVariable.category]?.[
|
||||
existingConfigVariable.name
|
||||
existingConfigVariable.name
|
||||
];
|
||||
|
||||
// Delete the config variable if it doesn't exist in the seed
|
||||
if (!configVariable) {
|
||||
await prisma.config.delete({
|
||||
where: {
|
||||
@@ -384,11 +217,15 @@ async function migrateConfigVariables() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update the config variable if it exists in the seed
|
||||
} else {
|
||||
const variableOrder = Object.keys(
|
||||
configVariables[existingConfigVariable.category]
|
||||
).indexOf(existingConfigVariable.name);
|
||||
// Update the config variable if the metadata changed
|
||||
} else if (
|
||||
JSON.stringify({
|
||||
...configVariable,
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
}) != JSON.stringify(existingConfigVariable)
|
||||
) {
|
||||
await prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -401,10 +238,8 @@ async function migrateConfigVariables() {
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
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 { AuthModule } from "./auth/auth.module";
|
||||
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AppController } from "./app.controller";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { FileModule } from "./file/file.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { OAuthModule } from "./oauth/oauth.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,21 +25,14 @@ import { UserModule } from "./user/user.module";
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@@ -33,55 +33,43 @@ export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private authTotpService: AuthTotpService,
|
||||
private config: ConfigService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Post("signUp")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@Throttle(10, 5 * 60)
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Req() { ip }: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
if (!this.config.get("share.allowRegistration"))
|
||||
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,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
result.accessToken
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("signIn")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@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) {
|
||||
this.authService.addTokensToResponse(
|
||||
response = this.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
result.accessToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,47 +77,32 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post("signIn/totp")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
const result = await this.authTotpService.signInTotp(dto);
|
||||
|
||||
this.authService.addTokensToResponse(
|
||||
response = this.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
result.accessToken
|
||||
);
|
||||
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(202)
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
await this.authService.requestResetPassword(email);
|
||||
return await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
@@ -140,15 +113,15 @@ export class AuthController {
|
||||
async updatePassword(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() dto: UpdatePasswordDTO,
|
||||
@Body() dto: UpdatePasswordDTO
|
||||
) {
|
||||
const result = await this.authService.updatePassword(
|
||||
user,
|
||||
dto.password,
|
||||
dto.oldPassword,
|
||||
dto.password
|
||||
);
|
||||
|
||||
this.authService.addTokensToResponse(response, result.refreshToken);
|
||||
response = this.addTokensToResponse(response, result.refreshToken);
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@@ -156,41 +129,29 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@Post("signOut")
|
||||
async signOut(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
const redirectURI = await this.authService.signOut(
|
||||
request.cookies.access_token,
|
||||
);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
response.cookie("access_token", "", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
await this.authService.signOut(request.cookies.access_token);
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
|
||||
if (typeof redirectURI === "string") {
|
||||
return { redirectURI: redirectURI.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
@Post("totp/enable")
|
||||
@@ -211,4 +172,22 @@ export class AuthController {
|
||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||
}
|
||||
|
||||
private addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
import { LdapService } from "./ldap.service";
|
||||
import { UserModule } from "../user/user.module";
|
||||
import { OAuthModule } from "../oauth/oauth.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
forwardRef(() => OAuthModule),
|
||||
UserModule,
|
||||
],
|
||||
imports: [JwtModule.register({}), EmailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import { Request, Response } from "express";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { OAuthService } from "../oauth/oauth.service";
|
||||
import { GenericOidcProvider } from "../oauth/provider/genericOidc.provider";
|
||||
import { UserSevice } from "../user/user.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { LdapService } from "./ldap.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -29,106 +21,64 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
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 hash = dto.password ? await argon.hash(dto.password) : null;
|
||||
const hash = await argon.hash(dto.password);
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: isAdmin ?? isFirstUser,
|
||||
isAdmin: isFirstUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
user.id
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
this.logger.log(`User ${user.email} signed up from IP ${ip}`);
|
||||
return { accessToken, refreshToken, user };
|
||||
return { accessToken, refreshToken };
|
||||
} 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`,
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username) {
|
||||
async signIn(dto: AuthSignInDTO) {
|
||||
if (!dto.email && !dto.username)
|
||||
throw new BadRequestException("Email or username is required");
|
||||
}
|
||||
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.password && (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 (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
if (this.config.get("ldap.enabled")) {
|
||||
/*
|
||||
* E-mail-like user credentials are passed as the email property
|
||||
* instead of the username. Since the username format does not matter
|
||||
* when searching for users in LDAP, we simply use the username
|
||||
* in whatever format it is provided.
|
||||
*/
|
||||
const ldapUsername = dto.username || dto.email;
|
||||
this.logger.debug(`Trying LDAP login for user ${ldapUsername}`);
|
||||
const ldapUser = await this.ldapService.authenticateUser(
|
||||
ldapUsername,
|
||||
dto.password,
|
||||
);
|
||||
if (ldapUser) {
|
||||
const user = await this.userService.findOrCreateFromLDAP(dto, ldapUser);
|
||||
this.logger.log(
|
||||
`Successful LDAP login for user ${ldapUsername} (${user.id}) 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");
|
||||
}
|
||||
|
||||
async generateToken(user: User, oauth?: { idToken?: string }) {
|
||||
// TODO: Make all old loginTokens invalid when a new one is created
|
||||
// Check if the user has TOTP enabled
|
||||
if (user.totpVerified && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||
if (user.totpVerified) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
oauth?.idToken,
|
||||
user.id
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -136,24 +86,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
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({
|
||||
where: { email },
|
||||
include: { resetPasswordToken: true },
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
if (user.ldapDN) {
|
||||
this.logger.log(
|
||||
`Failed password reset request for user ${email} because it is an LDAP user`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"This account can't reset its password here. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
@@ -169,13 +107,10 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
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({
|
||||
where: { resetPasswordToken: { token } },
|
||||
});
|
||||
@@ -194,11 +129,9 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
|
||||
const isPasswordValid =
|
||||
!user.password || (await argon.verify(user.password, oldPassword));
|
||||
|
||||
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const hash = await argon.hash(newPassword);
|
||||
|
||||
@@ -225,7 +158,7 @@ export class AuthService {
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,57 +169,12 @@ export class AuthService {
|
||||
}) || {};
|
||||
|
||||
if (refreshTokenId) {
|
||||
const oauthIDToken = await this.prisma.refreshToken
|
||||
.findFirst({
|
||||
select: { oauthIDToken: true },
|
||||
where: { id: refreshTokenId },
|
||||
})
|
||||
.then((refreshToken) => refreshToken?.oauthIDToken)
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
await this.prisma.refreshToken
|
||||
.delete({ where: { id: refreshTokenId } })
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
|
||||
if (typeof oauthIDToken === "string") {
|
||||
const [providerName, idTokenHint] = oauthIDToken.split(":");
|
||||
const provider = this.oAuthService.availableProviders()[providerName];
|
||||
let signOutFromProviderSupportedAndActivated = false;
|
||||
try {
|
||||
signOutFromProviderSupportedAndActivated = this.config.get(
|
||||
`oauth.${providerName}-signOut`,
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignore error if the provider is not supported or if the provider sign out is not activated
|
||||
}
|
||||
if (
|
||||
provider instanceof GenericOidcProvider &&
|
||||
signOutFromProviderSupportedAndActivated
|
||||
) {
|
||||
const configuration = await provider.getConfiguration();
|
||||
if (
|
||||
configuration.frontchannel_logout_supported &&
|
||||
URL.canParse(configuration.end_session_endpoint)
|
||||
) {
|
||||
const redirectURI = new URL(configuration.end_session_endpoint);
|
||||
redirectURI.searchParams.append(
|
||||
"post_logout_redirect_uri",
|
||||
this.config.get("general.appUrl"),
|
||||
);
|
||||
redirectURI.searchParams.append("id_token_hint", idTokenHint);
|
||||
redirectURI.searchParams.append(
|
||||
"client_id",
|
||||
this.config.get(`oauth.${providerName}-clientId`),
|
||||
);
|
||||
return redirectURI.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,19 +189,13 @@ export class AuthService {
|
||||
|
||||
return this.createAccessToken(
|
||||
refreshTokenMetaData.user,
|
||||
refreshTokenMetaData.id,
|
||||
refreshTokenMetaData.id
|
||||
);
|
||||
}
|
||||
|
||||
async createRefreshToken(userId: string, idToken?: string) {
|
||||
async createRefreshToken(userId: string) {
|
||||
const { id, token } = await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: moment()
|
||||
.add(this.config.get("general.sessionDuration"), "hours")
|
||||
.toDate(),
|
||||
oauthIDToken: idToken,
|
||||
},
|
||||
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
||||
});
|
||||
|
||||
return { refreshTokenId: id, refreshToken: token };
|
||||
@@ -328,52 +210,4 @@ export class AuthService {
|
||||
|
||||
return loginToken;
|
||||
}
|
||||
|
||||
addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, {
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
||||
});
|
||||
if (refreshToken)
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: isSecure,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(user: User, password: string) {
|
||||
if (!user.password && this.config.get("ldap.enabled")) {
|
||||
return !!this.ldapService.authenticateUser(user.username, password);
|
||||
}
|
||||
|
||||
return argon.verify(user.password, password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
@@ -16,34 +17,48 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
if (!dto.email && !dto.username)
|
||||
throw new BadRequestException("Email or username is required");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
const token = await this.prisma.loginToken.findFirst({
|
||||
where: {
|
||||
token: dto.loginToken,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!token || token.used)
|
||||
if (!token || token.userId != user.id || token.used)
|
||||
throw new UnauthorizedException("Invalid login token");
|
||||
|
||||
if (token.expiresAt < new Date())
|
||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||
|
||||
// Check the TOTP code
|
||||
const { totpSecret } = token.user;
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpSecret: true },
|
||||
});
|
||||
|
||||
if (!totpSecret) {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -54,17 +69,17 @@ export class AuthTotpService {
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } =
|
||||
await this.authService.createRefreshToken(token.user.id);
|
||||
await this.authService.createRefreshToken(user.id);
|
||||
const accessToken = await this.authService.createAccessToken(
|
||||
token.user,
|
||||
refreshTokenId,
|
||||
user,
|
||||
refreshTokenId
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
// Check if we have a secret already
|
||||
@@ -77,10 +92,14 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
const issuer = this.configService.get("general.appName");
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(user.username || user.email, issuer, secret);
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
this.config.get("general.appName"),
|
||||
secret
|
||||
);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -105,8 +124,9 @@ export class AuthTotpService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
|
||||
async verifyTotp(user: User, password: string, code: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
@@ -135,7 +155,7 @@ export class AuthTotpService {
|
||||
}
|
||||
|
||||
async disableTotp(user: User, password: string, code: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
|
||||
@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInDTO {
|
||||
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
@@ -8,7 +10,4 @@ export class AuthSignInDTO {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsString } from "class-validator";
|
||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||
|
||||
export class AuthSignInTotpDTO {
|
||||
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||
@IsString()
|
||||
totp: string;
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
oldPassword?: string;
|
||||
oldPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { inspect } from "node:util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { Client, Entry, InvalidCredentialsError } from "ldapts";
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
private readonly logger = new Logger(LdapService.name);
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly serviceConfig: ConfigService,
|
||||
) {}
|
||||
|
||||
private async createLdapConnection(): Promise<Client> {
|
||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
||||
if (!ldapUrl) {
|
||||
throw new Error("LDAP server URL is not defined");
|
||||
}
|
||||
|
||||
const ldapClient = new Client({
|
||||
url: ldapUrl,
|
||||
timeout: 15_000,
|
||||
connectTimeout: 15_000,
|
||||
});
|
||||
|
||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||
if (bindDn) {
|
||||
try {
|
||||
await ldapClient.bind(
|
||||
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;
|
||||
}
|
||||
|
||||
public async authenticateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Entry | null> {
|
||||
if (!username.match(/^[a-zA-Z0-9-_.@]+$/)) {
|
||||
this.logger.verbose(
|
||||
`Username ${username} does not match username pattern. Authentication failed.`,
|
||||
);
|
||||
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 { searchEntries } = await ldapClient.search(searchBase, {
|
||||
filter: searchQuery,
|
||||
scope: "sub",
|
||||
|
||||
attributes: ["*"],
|
||||
returnAttributeValues: true,
|
||||
});
|
||||
|
||||
if (searchEntries.length > 1) {
|
||||
/* too many users found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. Too many users found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
} else if (searchEntries.length == 0) {
|
||||
/* user not found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. No user found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetEntity = searchEntries[0];
|
||||
this.logger.verbose(
|
||||
`Trying to authenticate ${username} against LDAP user ${targetEntity.dn}`,
|
||||
);
|
||||
try {
|
||||
await ldapClient.bind(targetEntity.dn, password);
|
||||
return targetEntity;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
this.logger.verbose(
|
||||
`Failed to authenticate ${username} against ${targetEntity.dn}. Invalid credentials.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn(`User bind failure: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Connect error: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,7 @@ import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
config.get("internal.jwtSecret");
|
||||
super({
|
||||
jwtFromRequest: JwtStrategy.extractJWT,
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ClamScanService {
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
@@ -81,7 +81,7 @@ export class ClamScanService {
|
||||
});
|
||||
|
||||
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(
|
||||
private configService: ConfigService,
|
||||
private logoService: LogoService,
|
||||
private emailService: EmailService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -41,7 +41,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getByCategory(@Param("category") category: string) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.getByCategory(category),
|
||||
await this.configService.getByCategory(category)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.updateMany(data),
|
||||
await this.configService.updateMany(data)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export class ConfigController {
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||
}),
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
file: Express.Multer.File
|
||||
) {
|
||||
return await this.logoService.create(file.buffer);
|
||||
}
|
||||
|
||||
@@ -6,24 +6,17 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
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()
|
||||
export class ConfigService extends EventEmitter {
|
||||
export class ConfigService {
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
get(key: `${string}.${string}`): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => `${variable.category}.${variable.name}` == key,
|
||||
(variable) => `${variable.category}.${variable.name}` == key
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
@@ -96,7 +89,7 @@ export class ConfigService extends EventEmitter {
|
||||
configVariable.type != "text"
|
||||
) {
|
||||
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.emit("update", key, value);
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
description: string;
|
||||
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
|
||||
@@ -25,7 +28,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
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>[]) {
|
||||
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()
|
||||
export class LogoService {
|
||||
async create(file: Buffer) {
|
||||
const resized = await sharp(file).resize(900).toBuffer();
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||
this.createFavicon(file);
|
||||
this.createPWAIcons(file);
|
||||
}
|
||||
@@ -26,7 +25,7 @@ export class LogoService {
|
||||
fs.promises.writeFile(
|
||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||
resized,
|
||||
"binary",
|
||||
"binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { LogLevel } from "@nestjs/common";
|
||||
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||
export const DATABASE_URL =
|
||||
process.env.DATABASE_URL ||
|
||||
"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 LOG_LEVEL_AVAILABLE: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];
|
||||
export const LOG_LEVEL_DEFAULT: LogLevel = process.env.NODE_ENV === 'development' ? "verbose" : "log";
|
||||
export const LOG_LEVEL_ENV = `${process.env.PV_LOG_LEVEL || ""}`;
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`
|
||||
export const DATABASE_URL = process.env.DATABASE_URL || "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;
|
||||
@@ -17,19 +17,13 @@ export class EmailService {
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
throw new InternalServerErrorException("SMTP is disabled");
|
||||
|
||||
const username = this.config.get("smtp.username");
|
||||
const password = this.config.get("smtp.password");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: this.config.get("smtp.host"),
|
||||
port: this.config.get("smtp.port"),
|
||||
secure: this.config.get("smtp.port") == 465,
|
||||
auth:
|
||||
username || password ? { user: username, pass: password } : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: !this.config.get(
|
||||
"smtp.allowUnauthorizedCertificates",
|
||||
),
|
||||
auth: {
|
||||
user: this.config.get("smtp.username"),
|
||||
pass: this.config.get("smtp.password"),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,7 +32,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email",
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: email,
|
||||
subject,
|
||||
@@ -55,12 +49,12 @@ export class EmailService {
|
||||
shareId: string,
|
||||
creator?: User,
|
||||
description?: string,
|
||||
expiration?: Date,
|
||||
expiration?: Date
|
||||
) {
|
||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||
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(
|
||||
recipientEmail,
|
||||
@@ -69,20 +63,19 @@ export class EmailService {
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{creatorEmail}", creator?.email ?? "")
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{desc}", description ?? "No description")
|
||||
.replaceAll(
|
||||
"{expires}",
|
||||
moment(expiration).unix() != 0
|
||||
? moment(expiration).fromNow()
|
||||
: "in: never",
|
||||
),
|
||||
: "in: never"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
recipientEmail,
|
||||
@@ -90,13 +83,13 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"general.appUrl",
|
||||
"general.appUrl"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.sendMail(
|
||||
@@ -105,7 +98,7 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
.replaceAll("{url}", resetPasswordUrl)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +112,6 @@ export class EmailService {
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password)
|
||||
.replaceAll("{email}", recipientEmail),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +119,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email",
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
@@ -26,24 +25,21 @@ export class FileController {
|
||||
@SkipThrottle()
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async create(
|
||||
@Query()
|
||||
query: {
|
||||
id: string;
|
||||
name: string;
|
||||
chunkIndex: string;
|
||||
totalChunks: string;
|
||||
},
|
||||
@Query() query: any,
|
||||
|
||||
@Body() body: string,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
// Data can be empty if the file is empty
|
||||
const data = body.toString().split(",")[1] ?? "";
|
||||
|
||||
return await this.fileService.create(
|
||||
body,
|
||||
data,
|
||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||
{ id, name },
|
||||
shareId,
|
||||
shareId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +47,7 @@ export class FileController {
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
@@ -68,14 +64,13 @@ export class FileController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string,
|
||||
@Query("download") download = "true",
|
||||
@Query("download") download = "true"
|
||||
) {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
|
||||
const headers = {
|
||||
"Content-Type": file.metaData.mimeType,
|
||||
"Content-Length": file.metaData.size,
|
||||
"Content-Security-Policy": "script-src 'none'",
|
||||
};
|
||||
|
||||
if (download === "true") {
|
||||
@@ -86,14 +81,4 @@ export class FileController {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,31 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { validate as isValidUUID } from "uuid";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string,
|
||||
shareId: string
|
||||
) {
|
||||
if (!file.id) {
|
||||
file.id = crypto.randomUUID();
|
||||
} else if (!isValidUUID(file.id)) {
|
||||
throw new BadRequestException("Invalid file ID format");
|
||||
}
|
||||
if (!file.id) file.id = crypto.randomUUID();
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
@@ -45,15 +39,15 @@ export class FileService {
|
||||
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`)
|
||||
diskFileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
@@ -65,17 +59,10 @@ export class FileService {
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if there is enough space on the server
|
||||
const space = await fs.statfs(SHARE_DIRECTORY);
|
||||
const availableSpace = space.bavail * space.bsize;
|
||||
if (availableSpace < buffer.byteLength) {
|
||||
throw new InternalServerErrorException("Not enough space on the server");
|
||||
}
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
@@ -87,23 +74,23 @@ export class FileService {
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
);
|
||||
}
|
||||
|
||||
await fs.appendFile(
|
||||
fs.appendFileSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
buffer
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
await fs.rename(
|
||||
fs.renameSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
);
|
||||
const fileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}`)
|
||||
const fileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
@@ -125,7 +112,7 @@ export class FileService {
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
@@ -137,26 +124,14 @@ 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");
|
||||
|
||||
await fs.unlink(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,14 @@ import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { ShareService } from "src/share/share.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
constructor(
|
||||
private _shareService: ShareService,
|
||||
private _prisma: PrismaService,
|
||||
_config: ConfigService,
|
||||
private _prisma: PrismaService
|
||||
) {
|
||||
super(_shareService, _prisma, _config);
|
||||
super(_shareService, _prisma);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
@@ -26,7 +24,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId",
|
||||
"shareId"
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -54,7 +52,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded",
|
||||
"share_max_views_exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export class JobsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private fileService: FileService,
|
||||
private fileService: FileService
|
||||
) {}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -56,33 +56,11 @@ export class JobsService {
|
||||
|
||||
if (expiredReverseShares.length > 0) {
|
||||
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 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
let filesDeleted = 0;
|
||||
@@ -99,7 +77,7 @@ export class JobsService {
|
||||
|
||||
for (const file of temporaryFiles) {
|
||||
const stats = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
|
||||
);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
@@ -115,7 +93,7 @@ export class JobsService {
|
||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("1 * * * *")
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredTokens() {
|
||||
const { count: refreshTokenCount } =
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
import {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
LogLevel,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import * as bodyParser from "body-parser";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import {
|
||||
DATA_DIRECTORY,
|
||||
LOG_LEVEL_AVAILABLE,
|
||||
LOG_LEVEL_DEFAULT,
|
||||
LOG_LEVEL_ENV,
|
||||
} from "./constants";
|
||||
|
||||
function generateNestJsLogLevels(): LogLevel[] {
|
||||
if (LOG_LEVEL_ENV) {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_ENV as any);
|
||||
if (levelIndex === -1) {
|
||||
throw new Error(`log level ${LOG_LEVEL_ENV} unknown`);
|
||||
}
|
||||
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
} else {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_DEFAULT);
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
}
|
||||
}
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
|
||||
async function bootstrap() {
|
||||
const logLevels = generateNestJsLogLevels();
|
||||
Logger.log(`Showing ${logLevels.join(", ")} messages`);
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: logLevels,
|
||||
});
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
const config = app.get<ConfigService>(ConfigService);
|
||||
|
||||
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(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||
app.use(cookieParser());
|
||||
app.set("trust proxy", true);
|
||||
|
||||
@@ -74,11 +33,6 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(
|
||||
parseInt(process.env.BACKEND_PORT || process.env.PORT || "8080"),
|
||||
);
|
||||
|
||||
const logger = new Logger("UnhandledAsyncError");
|
||||
process.on("unhandledRejection", (e) => logger.error(e));
|
||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
state: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface OAuthSignInDto {
|
||||
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
idToken?: string;
|
||||
}
|
||||
@@ -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,57 +0,0 @@
|
||||
import { forwardRef, 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: [forwardRef(() => AuthModule)],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { forwardRef, 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";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
@Inject("OAUTH_PROVIDERS")
|
||||
private oAuthProviders: Record<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
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);
|
||||
}
|
||||
|
||||
availableProviders(): Record<string, OAuthProvider<unknown>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.oAuthProviders)
|
||||
.map(([providerName, provider]) => [
|
||||
[providerName, provider],
|
||||
this.config.get(`oauth.${providerName}-enabled`),
|
||||
])
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([provider, _]) => provider),
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
id: oauthUser.userId,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
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 keep letters, numbers, dots, and underscores. Truncate to 20 characters.
|
||||
let username = preferredUsername
|
||||
.replace(/[^a-zA-Z0-9._]/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, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
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,146 +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);
|
||||
}
|
||||
const limitedUsers = this.config.get("oauth.discord-limitedUsers");
|
||||
if (limitedUsers) {
|
||||
await this.checkLimitedUsers(user, limitedUsers);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "discord",
|
||||
providerId: user.id,
|
||||
providerUsername: user.global_name ?? user.username,
|
||||
email: user.email,
|
||||
idToken: `discord:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimitedUsers(user: DiscordUser, userIds: string) {
|
||||
if (!userIds.split(",").includes(user.id)) {
|
||||
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,290 +0,0 @@
|
||||
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Cache } from "cache-manager";
|
||||
import * as jmespath from "jmespath";
|
||||
import { nanoid } from "nanoid";
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
if (!idTokenData) {
|
||||
this.logger.error(
|
||||
`Can not get ID Token from response ${JSON.stringify(token.rawToken, undefined, 2)}`,
|
||||
);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
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 }),
|
||||
idToken: `${this.name}:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
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[];
|
||||
frontchannel_logout_supported?: boolean;
|
||||
end_session_endpoint?: 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,112 +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,
|
||||
idToken: `github:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
datasources: {
|
||||
@@ -14,6 +12,6 @@ export class PrismaService extends PrismaClient {
|
||||
},
|
||||
},
|
||||
});
|
||||
super.$connect().then(() => this.logger.log("Connected to the database"));
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,4 @@ export class CreateReverseShareDTO {
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
maxUseCount: number;
|
||||
|
||||
@IsBoolean()
|
||||
simplified: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
publicAccess: boolean;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ export class ReverseShareDTO {
|
||||
@Expose()
|
||||
shareExpiration: Date;
|
||||
|
||||
@Expose()
|
||||
token: string;
|
||||
|
||||
@Expose()
|
||||
simplified: boolean;
|
||||
|
||||
from(partial: Partial<ReverseShareDTO>) {
|
||||
return plainToClass(ReverseShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||
shares: Omit<
|
||||
MyShareDTO,
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword" | "size"
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||
>[];
|
||||
|
||||
@Expose()
|
||||
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
return partial.map((part) =>
|
||||
plainToClass(ReverseShareTokenWithShares, part, {
|
||||
excludeExtraneousValues: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
|
||||
export class ReverseShareController {
|
||||
constructor(
|
||||
private reverseShareService: ReverseShareService,
|
||||
private config: ConfigService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -36,12 +36,7 @@ export class ReverseShareController {
|
||||
return { token, link };
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Throttle(20, 60)
|
||||
@Get(":reverseShareToken")
|
||||
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||
@@ -49,7 +44,7 @@ export class ReverseShareController {
|
||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||
|
||||
return new ReverseShareDTO().from(
|
||||
await this.reverseShareService.getByToken(reverseShareToken),
|
||||
await this.reverseShareService.getByToken(reverseShareToken)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +52,7 @@ export class ReverseShareController {
|
||||
@UseGuards(JwtGuard)
|
||||
async getAllByUser(@GetUser() user: User) {
|
||||
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 { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -11,7 +10,7 @@ export class ReverseShareService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService,
|
||||
private fileService: FileService
|
||||
) {}
|
||||
|
||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||
@@ -20,27 +19,16 @@ export class ReverseShareService {
|
||||
.add(
|
||||
data.shareExpiration.split("-")[0],
|
||||
data.shareExpiration.split(
|
||||
"-",
|
||||
)[1] as moment.unitOfTime.DurationConstructor,
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.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");
|
||||
|
||||
if (globalMaxShareSize < data.maxShareSize)
|
||||
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({
|
||||
@@ -49,8 +37,6 @@ export class ReverseShareService {
|
||||
remainingUses: data.maxUseCount,
|
||||
maxShareSize: data.maxShareSize,
|
||||
sendEmailNotification: data.sendEmailNotification,
|
||||
simplified: data.simplified,
|
||||
publicAccess: data.publicAccess,
|
||||
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)
|
||||
id: string;
|
||||
|
||||
@Length(3, 30)
|
||||
@IsOptional()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
expiration: string;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
import { FileDTO } from "../../file/dto/file.dto";
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import {FileDTO} from "../../file/dto/file.dto";
|
||||
import {OmitType} from "@nestjs/swagger";
|
||||
|
||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
@@ -27,7 +27,7 @@ export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
|
||||
fromList(partial: Partial<MyShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }),
|
||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ export class ShareDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name?: string;
|
||||
|
||||
@Expose()
|
||||
expiration: Date;
|
||||
|
||||
@@ -26,16 +23,13 @@ export class ShareDTO {
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
@Expose()
|
||||
size: number;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<ShareDTO>[]) {
|
||||
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 {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private reverseShareService: ReverseShareService
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
@@ -20,8 +20,9 @@ export class CreateShareGuard extends JwtGuard {
|
||||
|
||||
if (!reverseShareTokenId) return false;
|
||||
|
||||
const isReverseShareTokenValid =
|
||||
await this.reverseShareService.isValid(reverseShareTokenId);
|
||||
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
||||
reverseShareTokenId
|
||||
);
|
||||
|
||||
return isReverseShareTokenValid;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
||||
|
||||
@Injectable()
|
||||
export class ShareOwnerGuard extends JwtGuard {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
export class ShareOwnerGuard implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId",
|
||||
"shareId"
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -34,20 +28,8 @@ export class ShareOwnerGuard extends JwtGuard {
|
||||
|
||||
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 not signed in, deny access
|
||||
if (!user) return false;
|
||||
|
||||
// If the user is the creator of the share, allow access
|
||||
return share.creatorId == user.id;
|
||||
return share.creatorId == (request.user as User).id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
@@ -8,26 +9,20 @@ import { Request } from "express";
|
||||
import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.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()
|
||||
export class ShareSecurityGuard extends JwtGuard {
|
||||
export class ShareSecurityGuard implements CanActivate {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private prisma: PrismaService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId",
|
||||
"shareId"
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -36,7 +31,7 @@ export class ShareSecurityGuard extends JwtGuard {
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true, reverseShare: true },
|
||||
include: { security: true },
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -49,29 +44,13 @@ export class ShareSecurityGuard extends JwtGuard {
|
||||
if (share.security?.password && !shareToken)
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required",
|
||||
"share_password_required"
|
||||
);
|
||||
|
||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||
throw new ForbiddenException(
|
||||
"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",
|
||||
"share_token_required"
|
||||
);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId",
|
||||
"shareId"
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
@@ -10,15 +10,11 @@ import {
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import * as moment from "moment";
|
||||
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 { AdminShareDTO } from "./dto/adminShare.dto";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
import { MyShareDTO } from "./dto/myShare.dto";
|
||||
import { ShareDTO } from "./dto/share.dto";
|
||||
@@ -29,25 +25,15 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||
import { ShareService } from "./share.service";
|
||||
import { CompletedShareDTO } from "./dto/shareComplete.dto";
|
||||
@Controller("shares")
|
||||
export class ShareController {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@Get("all")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getAllShares() {
|
||||
return new AdminShareDTO().fromList(await this.shareService.getShares());
|
||||
}
|
||||
constructor(private shareService: ShareService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
async getMyShares(@GetUser() user: User) {
|
||||
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));
|
||||
}
|
||||
|
||||
@Get(":id/from-owner")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async getFromOwner(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/metaData")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getMetaData(@Param("id") id: string) {
|
||||
@@ -74,66 +54,46 @@ export class ShareController {
|
||||
async create(
|
||||
@Body() body: CreateShareDTO,
|
||||
@Req() request: Request,
|
||||
@GetUser() user: User,
|
||||
@GetUser() user: User
|
||||
) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
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")
|
||||
@HttpCode(202)
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new CompletedShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token),
|
||||
return new ShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token)
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/complete")
|
||||
@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,
|
||||
},
|
||||
})
|
||||
@Throttle(10, 60)
|
||||
@Get("isShareIdAvailable/:id")
|
||||
async isShareIdAvailable(@Param("id") id: string) {
|
||||
return this.shareService.isShareIdAvailable(id);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@Throttle(20, 5 * 60)
|
||||
@UseGuards(ShareTokenSecurity)
|
||||
@Post(":id/token")
|
||||
async getShareToken(
|
||||
@Param("id") id: string,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: SharePasswordDto,
|
||||
@Body() body: SharePasswordDto
|
||||
) {
|
||||
const token = await this.shareService.getShareToken(id, body.password);
|
||||
|
||||
this.clearShareTokenCookies(request, response);
|
||||
response.cookie(`share_${id}_token`, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -141,32 +101,4 @@ export class ShareController {
|
||||
|
||||
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: [
|
||||
JwtModule.register({}),
|
||||
EmailModule,
|
||||
forwardRef(() => ClamScanModule),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
forwardRef(() => FileModule),
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Share, User } from "@prisma/client";
|
||||
import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
@@ -16,7 +16,6 @@ import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@@ -29,7 +28,7 @@ export class ShareService {
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private clamScanService: ClamScanService,
|
||||
private clamScanService: ClamScanService
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
||||
@@ -46,29 +45,25 @@ export class ShareService {
|
||||
let expirationDate: Date;
|
||||
|
||||
// If share is created by a reverse share token override the expiration date
|
||||
const reverseShare =
|
||||
await this.reverseShareService.getByToken(reverseShareToken);
|
||||
const reverseShare = await this.reverseShareService.getByToken(
|
||||
reverseShareToken
|
||||
);
|
||||
if (reverseShare) {
|
||||
expirationDate = reverseShare.shareExpiration;
|
||||
} else {
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(share.expiration);
|
||||
|
||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
||||
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
(expiresNever ||
|
||||
parsedExpiration >
|
||||
moment()
|
||||
.add(this.config.get("share.maxExpiration"), "hours")
|
||||
.toDate())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
);
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
} else {
|
||||
expirationDate = moment(0).toDate();
|
||||
}
|
||||
|
||||
expirationDate = parsedExpiration;
|
||||
}
|
||||
|
||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||
@@ -109,7 +104,7 @@ export class ShareService {
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: this.config.get("share.zipCompressionLevel") },
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
||||
|
||||
@@ -139,13 +134,13 @@ export class ShareService {
|
||||
|
||||
if (share.files.length == 0)
|
||||
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
|
||||
if (share.files.length > 1)
|
||||
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
|
||||
@@ -155,19 +150,18 @@ export class ShareService {
|
||||
share.id,
|
||||
share.creator,
|
||||
share.description,
|
||||
share.expiration,
|
||||
share.expiration
|
||||
);
|
||||
}
|
||||
|
||||
const notifyReverseShareCreator = share.reverseShare
|
||||
? this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
: undefined;
|
||||
|
||||
if (notifyReverseShareCreator) {
|
||||
if (
|
||||
share.reverseShare &&
|
||||
this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
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({
|
||||
where: { id },
|
||||
data: { uploadLocked: false, isZipReady: false },
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
data: { uploadLocked: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +201,6 @@ export class ShareService {
|
||||
return shares.map((share) => {
|
||||
return {
|
||||
...share,
|
||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
};
|
||||
});
|
||||
@@ -245,11 +210,7 @@ export class ShareService {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: {
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
files: true,
|
||||
creator: true,
|
||||
security: true,
|
||||
},
|
||||
@@ -277,14 +238,13 @@ export class ShareService {
|
||||
return share;
|
||||
}
|
||||
|
||||
async remove(shareId: string, isDeleterAdmin = false) {
|
||||
async remove(shareId: string) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
|
||||
if (!share.creatorId && !isDeleterAdmin)
|
||||
if (!share.creatorId)
|
||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
@@ -315,27 +275,17 @@ export class ShareService {
|
||||
},
|
||||
});
|
||||
|
||||
if (share?.security?.password) {
|
||||
if (!password) {
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required",
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon.verify(
|
||||
share.security.password,
|
||||
password,
|
||||
);
|
||||
if (!isPasswordValid) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
if (
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded",
|
||||
"share_max_views_exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,29 +295,22 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async generateShareToken(shareId: string) {
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
const tokenPayload = {
|
||||
shareId,
|
||||
shareCreatedAt: moment(createdAt).unix(),
|
||||
iat: moment().unix(),
|
||||
};
|
||||
|
||||
const tokenOptions: JwtSignOptions = {
|
||||
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);
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
shareId,
|
||||
},
|
||||
{
|
||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async verifyShareToken(shareId: string, token: string) {
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
@@ -378,10 +321,7 @@ export class ShareService {
|
||||
ignoreExpiration: moment(expiration).isSame(0),
|
||||
});
|
||||
|
||||
return (
|
||||
claims.shareId == shareId &&
|
||||
claims.shareCreatedAt == moment(createdAt).unix()
|
||||
);
|
||||
return claims.shareId == shareId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PartialType, PickType } from "@nestjs/swagger";
|
||||
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class UpdateOwnUserDTO extends PartialType(
|
||||
PickType(UserDTO, ["username", "email"] as const),
|
||||
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||
) {}
|
||||
|
||||
@@ -16,32 +16,22 @@ export class UserDTO {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
|
||||
@Expose()
|
||||
isLdap: boolean;
|
||||
|
||||
ldapDN?: string;
|
||||
|
||||
@Expose()
|
||||
totpVerified: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
const result = plainToClass(UserDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
result.isLdap = partial.ldapDN?.length > 0;
|
||||
return result;
|
||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<UserDTO>[]) {
|
||||
return partial.map((part) => this.from(part));
|
||||
return partial.map((part) =>
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -15,7 +14,6 @@ import { Response } from "express";
|
||||
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 { ConfigService } from "../config/config.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
@@ -24,51 +22,37 @@ import { UserSevice } from "./user.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
constructor(
|
||||
private userService: UserSevice,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
constructor(private userService: UserSevice) {}
|
||||
|
||||
// Own user operations
|
||||
@Get("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user?: User) {
|
||||
if (!user) return null;
|
||||
const userDTO = new UserDTO().from(user);
|
||||
userDTO.hasPassword = !!user.password;
|
||||
return userDTO;
|
||||
async getCurrentUser(@GetUser() user: User) {
|
||||
return new UserDTO().from(user);
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async updateCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Body() data: UpdateOwnUserDTO,
|
||||
@Body() data: UpdateOwnUserDTO
|
||||
) {
|
||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@HttpCode(204)
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
await this.userService.delete(user.id);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
|
||||
response.cookie("access_token", "accessToken", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
return new UserDTO().from(await this.userService.delete(user.id));
|
||||
}
|
||||
|
||||
// Global user operations
|
||||
|
||||
@@ -2,12 +2,10 @@ import { Module } from "@nestjs/common";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { UserController } from "./user.controller";
|
||||
import { UserSevice } from "./user.service";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
|
||||
@Module({
|
||||
imports: [EmailModule, FileModule],
|
||||
imports: [EmailModule],
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
exports: [UserSevice],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { Entry } from "ldapts";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { inspect } from "util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { FileService } from "../file/file.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
private readonly logger = new Logger(UserSevice.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
private fileService: FileService,
|
||||
private configService: ConfigService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
@@ -55,7 +46,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +66,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,146 +74,6 @@ export class UserSevice {
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { shares: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
if (user.isAdmin) {
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
|
||||
if (userCount === 1) {
|
||||
throw new BadRequestException("Cannot delete the last admin user");
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
user.shares.map((share) => this.fileService.deleteAllFiles(share.id)),
|
||||
);
|
||||
|
||||
return await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findOrCreateFromLDAP(
|
||||
providedCredentials: AuthSignInDTO,
|
||||
ldapEntry: Entry,
|
||||
) {
|
||||
const fieldNameMemberOf = this.configService.get("ldap.fieldNameMemberOf");
|
||||
const fieldNameEmail = this.configService.get("ldap.fieldNameEmail");
|
||||
|
||||
let isAdmin = false;
|
||||
if (fieldNameMemberOf in ldapEntry) {
|
||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
||||
const entryGroups = Array.isArray(ldapEntry[fieldNameMemberOf])
|
||||
? ldapEntry[fieldNameMemberOf]
|
||||
: [ldapEntry[fieldNameMemberOf]];
|
||||
isAdmin = entryGroups.includes(adminGroup) ?? false;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the member field ${fieldNameMemberOf} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
let userEmail: string | null = null;
|
||||
if (fieldNameEmail in ldapEntry) {
|
||||
const value = Array.isArray(ldapEntry[fieldNameEmail])
|
||||
? ldapEntry[fieldNameEmail][0]
|
||||
: ldapEntry[fieldNameEmail];
|
||||
if (value) {
|
||||
userEmail = value.toString();
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the email field ${fieldNameEmail} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (providedCredentials.email) {
|
||||
/* if LDAP does not provides an users email address, take the user provided email address instead */
|
||||
userEmail = providedCredentials.email;
|
||||
}
|
||||
|
||||
const randomId = crypto.randomUUID();
|
||||
const placeholderUsername = `ldap_user_${randomId}`;
|
||||
const placeholderEMail = `${randomId}@ldap.local`;
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.upsert({
|
||||
create: {
|
||||
username: providedCredentials.username ?? placeholderUsername,
|
||||
email: userEmail ?? placeholderEMail,
|
||||
password: await argon.hash(crypto.randomUUID()),
|
||||
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
update: {
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
where: {
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.username === placeholderUsername) {
|
||||
/* Give the user a human readable name if the user has been created with a placeholder username */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
username: `user_${user.id}`,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
user.username = newUser.username;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`Failed to update users ${user.id} placeholder username: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (userEmail && userEmail !== user.email) {
|
||||
/* Sync users email if it has changed */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: userEmail,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
this.logger.log(
|
||||
`Updated users ${user.id} email from ldap from ${user.email} to ${userEmail}.`,
|
||||
);
|
||||
user.email = newUser.email;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Failed to update users ${user.id} email to ${userEmail}: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} 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();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" 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();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" 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,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2021",
|
||||
"lib": [
|
||||
"ES2021"
|
||||
],
|
||||
"target": "es2017",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"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:
|
||||
clamav:
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
pingvin-share:
|
||||
image: stonith404/pingvin-share
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- TRUST_PROXY=false # Set to true if a reverse proxy is in front of the container
|
||||
volumes:
|
||||
- "./data:/opt/app/backend/data"
|
||||
- "./data/images:/opt/app/frontend/public/img"
|
||||
|
||||
# To add ClamAV, to scan your shares for malicious files,
|
||||
# see https://stonith404.github.io/pingvin-share/setup/integrations/#clamav-docker-only
|
||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
||||
# depends_on:
|
||||
# clamav:
|
||||
# condition: service_healthy
|
||||
# Optional: Add ClamAV (see README.md)
|
||||
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||
# clamav:
|
||||
# restart: unless-stopped
|
||||
# image: clamav/clamav
|
||||
|
||||
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')],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user