Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e195565630 | ||
|
|
520f9abcf7 | ||
|
|
bfbe8de98a | ||
|
|
d5cd3002a1 | ||
|
|
77a092a3cf | ||
|
|
613bae9033 | ||
|
|
2e692241c5 | ||
|
|
1e96011793 | ||
|
|
522a041ca1 | ||
|
|
ce6430da9f | ||
|
|
2b3ce3ffd2 | ||
|
|
104cc06145 | ||
|
|
4a50a5aa3b | ||
|
|
d6b8b56247 | ||
|
|
5883dff4cf | ||
|
|
511ae933fa | ||
|
|
df2521b192 | ||
|
|
8f16d6b53e | ||
|
|
3310fe53b3 | ||
|
|
adc4af996d | ||
|
|
61edc4f4f6 | ||
|
|
eba7984a0f | ||
|
|
69752b8b41 | ||
|
|
ee73293c0f | ||
|
|
5553607ffe | ||
|
|
2ca6e6ee5f | ||
|
|
18135b0ec0 | ||
|
|
f8bfb8ec3c | ||
|
|
187911e334 | ||
|
|
64acae11a2 | ||
|
|
6b39adfd03 | ||
|
|
d9cfe697d6 | ||
|
|
67a0fc6ea5 | ||
|
|
b13a81a88c | ||
|
|
97dc3ecfdd | ||
|
|
d00d52baa9 | ||
|
|
4c8848a2d9 | ||
|
|
3c8500008d | ||
|
|
325122b802 | ||
|
|
7dc2e56fee | ||
|
|
8b3e28bac8 | ||
|
|
347026b6d3 | ||
|
|
5a204d38a4 | ||
|
|
2eeb858f36 | ||
|
|
67faa860da | ||
|
|
beca26871d | ||
|
|
15d1756a4e | ||
|
|
be202d3d41 | ||
|
|
f0e785b1a2 | ||
|
|
92e1e82e09 | ||
|
|
0670aaa331 | ||
|
|
10b71e7035 | ||
|
|
dee70987eb | ||
|
|
3d2b978daf | ||
|
|
e813da05ae | ||
|
|
1fba0fd546 | ||
|
|
96cd353669 | ||
|
|
3e0735c620 | ||
|
|
d05988f281 | ||
|
|
42a985be04 | ||
|
|
af472af3bb | ||
|
|
f53f71f054 | ||
|
|
5622f9eb2f | ||
|
|
02b9abf6c5 | ||
|
|
6a4c3bf58f | ||
|
|
64efac5b68 | ||
|
|
8c5c696c51 | ||
|
|
01da83cdf6 | ||
|
|
cfcc5cebac | ||
|
|
b96878b6b1 | ||
|
|
9c381a2ed6 | ||
|
|
4f9b4f38f6 | ||
|
|
c98b237259 | ||
|
|
17d593a794 | ||
|
|
ac580b79b4 | ||
|
|
4186a768b3 | ||
|
|
4924f76394 | ||
|
|
f1f514dff7 | ||
|
|
94e2a6110d | ||
|
|
7716f5c0ce | ||
|
|
0a963bfaf1 | ||
|
|
472c93d548 | ||
|
|
93aacca9b4 | ||
|
|
3505669135 | ||
|
|
fe735f9704 | ||
|
|
3563715f57 | ||
|
|
14c2185e6f | ||
|
|
27ee9fb6cb | ||
|
|
601772d2f4 | ||
|
|
0e66be5f08 | ||
|
|
4cabcfb715 | ||
|
|
e5e9d85d39 | ||
|
|
70fd2d94be | ||
|
|
e5a0c649e3 | ||
|
|
414bcecbb5 | ||
|
|
968352cb6c | ||
|
|
355f860387 | ||
|
|
083d82c28b | ||
|
|
046c630abf | ||
|
|
d2bfb9a55f | ||
|
|
fccf57e9e4 | ||
|
|
e1a68f75f7 | ||
|
|
9d9cc7b4ab | ||
|
|
d1cde75a66 | ||
|
|
bbc81d8dd0 | ||
|
|
0cdc04bfb5 | ||
|
|
367f804a49 | ||
|
|
9193a79b9a | ||
|
|
31366d961f | ||
|
|
2dac38560b | ||
|
|
db2720ab7b | ||
|
|
6d6b9e81ff | ||
|
|
f9ddd7bacd | ||
|
|
3773432eb5 | ||
|
|
46783ce463 | ||
|
|
c0cc16fa43 | ||
|
|
4fd29037a0 | ||
|
|
1c7832ad1f | ||
|
|
962ec27df4 | ||
|
|
9268e35141 | ||
|
|
e8be0d60e6 | ||
|
|
0eabf78f13 | ||
|
|
4136bf5778 | ||
|
|
42b3604e2a | ||
|
|
84f4c39c1e | ||
|
|
bfef246d98 | ||
|
|
3b89fb950a | ||
|
|
7afda85f03 | ||
|
|
a3a7a5d9ab | ||
|
|
74cd520cb8 | ||
|
|
a511f24a6b | ||
|
|
b3862f3f3e | ||
|
|
d147614f76 | ||
|
|
c999df15e0 | ||
|
|
908d6e298f | ||
|
|
44c4a2e269 | ||
|
|
dc060f258b | ||
|
|
3b1c9f1efb | ||
|
|
a45184995f | ||
|
|
b717663b5c | ||
|
|
0e12ba87bc | ||
|
|
ec1feadee9 | ||
|
|
2e0d8d4fed | ||
|
|
b7f0f9d3ee |
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -31,14 +31,13 @@ body:
|
|||||||
label: "👎 Actual Behavior"
|
label: "👎 Actual Behavior"
|
||||||
description: "What did actually happen? Add screenshots, if applicable."
|
description: "What did actually happen? Add screenshots, if applicable."
|
||||||
placeholder: "It actually ..."
|
placeholder: "It actually ..."
|
||||||
- type: input
|
- type: textarea
|
||||||
id: operating-system
|
id: operating-system
|
||||||
attributes:
|
attributes:
|
||||||
label: "🌐 Browser"
|
label: "📜 Logs"
|
||||||
description: "Which browser do you use?"
|
description: "Paste any relevant logs here."
|
||||||
placeholder: "Firefox"
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: false
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 💬 Discord
|
||||||
|
url: https://discord.gg/wHRQ9nFRcK
|
||||||
|
about: For help and chatting with the community
|
||||||
17
.github/ISSUE_TEMPLATE/question.yml
vendored
17
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,17 +0,0 @@
|
|||||||
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.
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -38,6 +38,9 @@ yarn-error.log*
|
|||||||
# project specific
|
# project specific
|
||||||
/backend/data/
|
/backend/data/
|
||||||
/data/
|
/data/
|
||||||
|
/docs/build/
|
||||||
|
/docs/.docusaurus
|
||||||
|
/docs/.cache-loader
|
||||||
|
|
||||||
# Jetbrains specific (webstorm)
|
# Jetbrains specific (webstorm)
|
||||||
.idea/**/**
|
.idea/**/**
|
||||||
|
|||||||
193
CHANGELOG.md
193
CHANGELOG.md
@@ -1,3 +1,196 @@
|
|||||||
|
## [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)
|
## [0.23.1](https://github.com/stonith404/pingvin-share/compare/v0.23.0...v0.23.1) (2024-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
15
Caddyfile
15
Caddyfile
@@ -1,15 +0,0 @@
|
|||||||
:3000 {
|
|
||||||
# Reverse proxy for /api
|
|
||||||
reverse_proxy /api/* http://localhost:8080 {
|
|
||||||
header_up X-Forwarded-Host {host}:{server_port}
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto {scheme}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reverse proxy for all other requests
|
|
||||||
reverse_proxy http://localhost:3333 {
|
|
||||||
header_up X-Forwarded-Host {host}:{server_port}
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
|
||||||
header_up X-Forwarded-Proto {scheme}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
Dockerfile
10
Dockerfile
@@ -30,13 +30,10 @@ RUN npm run build && npm prune --production
|
|||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
ENV NODE_ENV=docker
|
ENV NODE_ENV=docker
|
||||||
|
|
||||||
# Install Caddy
|
|
||||||
RUN apk update --no-cache \
|
RUN apk update --no-cache \
|
||||||
&& apk upgrade --no-cache \
|
&& apk upgrade --no-cache \
|
||||||
&& apk add --no-cache curl caddy
|
&& apk add --no-cache curl caddy
|
||||||
|
|
||||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
||||||
@@ -49,12 +46,13 @@ COPY --from=backend-builder /opt/app/dist ./dist
|
|||||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||||
COPY --from=backend-builder /opt/app/package.json ./
|
COPY --from=backend-builder /opt/app/package.json ./
|
||||||
|
|
||||||
|
COPY ./reverse-proxy /etc/caddy
|
||||||
|
COPY ./scripts/docker-entrypoint.sh /opt/app/docker-entrypoint.sh
|
||||||
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Health check remains unchanged
|
|
||||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
# Application startup updated for Caddy
|
CMD ["sh", "/opt/app/docker-entrypoint.sh"]
|
||||||
CMD cp -rn /tmp/img/* /opt/app/frontend/public/img && caddy run --config /etc/caddy/Caddyfile & PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
|
|
||||||
143
README.md
143
README.md
@@ -1,12 +1,10 @@
|
|||||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
---
|
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
||||||
|
|
||||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
Pingvin Share is a self-hosted file sharing platform and an alternative for WeTransfer.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -15,6 +13,8 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
- Set an expiration date for shares
|
- Set an expiration date for shares
|
||||||
- Secure shares with visitor limits and passwords
|
- Secure shares with visitor limits and passwords
|
||||||
- Email recipients
|
- Email recipients
|
||||||
|
- Reverse shares
|
||||||
|
- OIDC and LDAP authentication
|
||||||
- Integration with ClamAV for security scans
|
- Integration with ClamAV for security scans
|
||||||
|
|
||||||
## 🐧 Get to know Pingvin Share
|
## 🐧 Get to know Pingvin Share
|
||||||
@@ -26,143 +26,26 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
## ⌨️ Setup
|
## ⌨️ Setup
|
||||||
|
|
||||||
> Note: Pingvin Share is in its early stages and may contain bugs.
|
|
||||||
|
|
||||||
### Installation with Docker (recommended)
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` file
|
1. Download the `docker-compose.yml` file
|
||||||
2. Run `docker-compose up -d`
|
2. Run `docker compose up -d`
|
||||||
|
|
||||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
### Stand-alone Installation
|
> [!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.
|
||||||
|
|
||||||
Required tools:
|
## 📚 Documentation
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||||
- [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
|
|
||||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
|
||||||
pm2 start --name="pingvin-share-frontend" .next/standalone/server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./Caddyfile`.
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
#### OAuth 2 Login
|
|
||||||
|
|
||||||
View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information.
|
|
||||||
|
|
||||||
### 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 install
|
|
||||||
npm run build
|
|
||||||
pm2 restart pingvin-share-backend
|
|
||||||
|
|
||||||
# Start the frontend
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
|
||||||
pm2 restart pingvin-share-frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
|
|
||||||
|
|
||||||
#### Environment variables
|
|
||||||
|
|
||||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
|
||||||
|
|
||||||
##### Backend
|
|
||||||
|
|
||||||
| Variable | Default Value | Description |
|
|
||||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
|
||||||
| `PORT` | `8080` | The port on which the backend listens. |
|
|
||||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
|
||||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
|
||||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
|
||||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
|
||||||
|
|
||||||
##### Frontend
|
|
||||||
|
|
||||||
| Variable | Default Value | Description |
|
|
||||||
| --------- | ----------------------- | ---------------------------------------- |
|
|
||||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
|
||||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
|
||||||
|
|
||||||
## 🖤 Contribute
|
## 🖤 Contribute
|
||||||
|
|
||||||
### Translations
|
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).
|
||||||
|
|
||||||
You can help to translate Pingvin Share into your language.
|
## ❤️ Sponsors
|
||||||
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
|
|
||||||
|
|
||||||
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
|
Thank you for supporting Pingvin Share 🙏
|
||||||
|
|
||||||
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
|
- [@COMPLEXWASTAKEN](https://github.com/COMPLEXWASTAKEN)
|
||||||
|
|
||||||
### Project
|
|
||||||
|
|
||||||
You're very welcome to contribute to Pingvin Share! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
|
||||||
|
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).
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## 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).
|
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).
|
||||||
|
|||||||
11444
backend/package-lock.json
generated
11444
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.23.1",
|
"version": "1.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
@@ -13,73 +13,74 @@
|
|||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/cache-manager": "^2.1.0",
|
"@nestjs/cache-manager": "^2.2.2",
|
||||||
"@nestjs/common": "^10.1.2",
|
"@nestjs/common": "^10.4.3",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.2.3",
|
||||||
"@nestjs/core": "^10.1.2",
|
"@nestjs/core": "^10.4.3",
|
||||||
"@nestjs/jwt": "^10.1.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.0",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.1.2",
|
"@nestjs/platform-express": "^10.4.3",
|
||||||
"@nestjs/schedule": "^3.0.1",
|
"@nestjs/schedule": "^4.1.1",
|
||||||
"@nestjs/swagger": "^7.1.4",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^4.2.1",
|
"@nestjs/throttler": "^6.2.1",
|
||||||
"@prisma/client": "^5.0.0",
|
"@prisma/client": "^5.19.1",
|
||||||
"archiver": "^5.3.1",
|
"@types/jmespath": "^0.15.2",
|
||||||
"argon2": "^0.30.3",
|
"archiver": "^7.0.1",
|
||||||
"body-parser": "^1.20.2",
|
"argon2": "^0.41.1",
|
||||||
"cache-manager": "^5.2.4",
|
"body-parser": "^1.20.3",
|
||||||
"clamscan": "^2.1.2",
|
"cache-manager": "^5.7.6",
|
||||||
|
"clamscan": "^2.3.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.1",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
"jmespath": "^0.16.0",
|
||||||
|
"ldapts": "^7.2.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.30.1",
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"nodemailer": "^6.9.15",
|
||||||
"nodemailer": "^6.9.4",
|
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.32.4",
|
"sharp": "^0.33.5",
|
||||||
"ts-node": "^10.9.1"
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.1.10",
|
"@nestjs/cli": "^10.4.5",
|
||||||
"@nestjs/schematics": "^10.0.1",
|
"@nestjs/schematics": "^10.1.4",
|
||||||
"@nestjs/testing": "^10.1.2",
|
"@nestjs/testing": "^10.4.3",
|
||||||
"@types/archiver": "^5.3.2",
|
"@types/archiver": "^6.0.2",
|
||||||
"@types/clamscan": "^2.0.4",
|
"@types/clamscan": "^2.0.8",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/cron": "^2.0.1",
|
"@types/cron": "^2.4.0",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.21",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.4.5",
|
"@types/node": "^22.5.5",
|
||||||
"@types/node-fetch": "^2.6.6",
|
"@types/nodemailer": "^6.4.16",
|
||||||
"@types/nodemailer": "^6.4.9",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-jwt": "^3.0.9",
|
"@types/qrcode-svg": "^1.1.5",
|
||||||
"@types/qrcode-svg": "^1.1.1",
|
"@types/sharp": "^0.32.0",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/supertest": "^2.0.12",
|
"@typescript-eslint/eslint-plugin": "^8.6.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
"@typescript-eslint/parser": "^8.6.0",
|
||||||
"@typescript-eslint/parser": "^6.2.0",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^9.10.0",
|
||||||
"eslint-config-prettier": "^8.9.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"newman": "^5.3.2",
|
"newman": "^6.2.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.3.3",
|
||||||
"prisma": "^5.0.0",
|
"prisma": "^5.19.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.4",
|
"ts-loader": "^9.5.1",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.6.2",
|
||||||
"wait-on": "^7.0.1"
|
"wait-on": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Share" ADD COLUMN "name" TEXT;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
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");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
||||||
@@ -16,6 +16,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
password String?
|
password String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
|
ldapDN String? @unique
|
||||||
|
|
||||||
shares Share[]
|
shares Share[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
@@ -39,6 +40,8 @@ model RefreshToken {
|
|||||||
|
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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 {
|
model LoginToken {
|
||||||
@@ -75,6 +78,7 @@ model Share {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
name String?
|
||||||
uploadLocked Boolean @default(false)
|
uploadLocked Boolean @default(false)
|
||||||
isZipReady Boolean @default(false)
|
isZipReady Boolean @default(false)
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
@@ -102,6 +106,8 @@ model ReverseShare {
|
|||||||
maxShareSize String
|
maxShareSize String
|
||||||
sendEmailNotification Boolean
|
sendEmailNotification Boolean
|
||||||
remainingUses Int
|
remainingUses Int
|
||||||
|
simplified Boolean @default(false)
|
||||||
|
publicAccess Boolean @default(true)
|
||||||
|
|
||||||
creatorId String
|
creatorId String
|
||||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const configVariables: ConfigVariables = {
|
|||||||
defaultValue: "true",
|
defaultValue: "true",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
sessionDuration: {
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "2160",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
share: {
|
share: {
|
||||||
allowRegistration: {
|
allowRegistration: {
|
||||||
@@ -56,12 +61,16 @@ const configVariables: ConfigVariables = {
|
|||||||
defaultValue: "10000000",
|
defaultValue: "10000000",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
autoOpenShareModal: {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
enableShareEmailRecipients: {
|
enableShareEmailRecipients: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
shareRecipientsSubject: {
|
shareRecipientsSubject: {
|
||||||
@@ -98,7 +107,7 @@ const configVariables: ConfigVariables = {
|
|||||||
inviteMessage: {
|
inviteMessage: {
|
||||||
type: "text",
|
type: "text",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
'Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYou can use the email "{email}" and the password "{password}" to sign in.\n\nPingvin Share 🐧',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
smtp: {
|
smtp: {
|
||||||
@@ -107,6 +116,12 @@ const configVariables: ConfigVariables = {
|
|||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
allowUnauthorizedCertificates: {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
@@ -129,6 +144,51 @@ const configVariables: ConfigVariables = {
|
|||||||
obscured: true,
|
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: {
|
oauth: {
|
||||||
"allowRegistration": {
|
"allowRegistration": {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
@@ -138,6 +198,11 @@ const configVariables: ConfigVariables = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "true",
|
defaultValue: "true",
|
||||||
},
|
},
|
||||||
|
"disablePassword": {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
"github-enabled": {
|
"github-enabled": {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
@@ -189,6 +254,10 @@ const configVariables: ConfigVariables = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
"discord-limitedUsers": {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
"discord-clientId": {
|
"discord-clientId": {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
@@ -206,10 +275,26 @@ const configVariables: ConfigVariables = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
"oidc-signOut": {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
},
|
||||||
"oidc-usernameClaim": {
|
"oidc-usernameClaim": {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
"oidc-rolePath": {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
"oidc-roleGeneralAccess": {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
"oidc-roleAdminAccess": {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
"oidc-clientId": {
|
"oidc-clientId": {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
@@ -219,7 +304,7 @@ const configVariables: ConfigVariables = {
|
|||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConfigVariables = {
|
type ConfigVariables = {
|
||||||
@@ -271,12 +356,15 @@ async function seedConfigVariables() {
|
|||||||
|
|
||||||
async function migrateConfigVariables() {
|
async function migrateConfigVariables() {
|
||||||
const existingConfigVariables = await prisma.config.findMany();
|
const existingConfigVariables = await prisma.config.findMany();
|
||||||
|
const orderMap: { [category: string]: number } = {};
|
||||||
|
|
||||||
for (const existingConfigVariable of existingConfigVariables) {
|
for (const existingConfigVariable of existingConfigVariables) {
|
||||||
const configVariable =
|
const configVariable =
|
||||||
configVariables[existingConfigVariable.category]?.[
|
configVariables[existingConfigVariable.category]?.[
|
||||||
existingConfigVariable.name
|
existingConfigVariable.name
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Delete the config variable if it doesn't exist in the seed
|
||||||
if (!configVariable) {
|
if (!configVariable) {
|
||||||
await prisma.config.delete({
|
await prisma.config.delete({
|
||||||
where: {
|
where: {
|
||||||
@@ -287,15 +375,11 @@ async function migrateConfigVariables() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the config variable if the metadata changed
|
// Update the config variable if it exists in the seed
|
||||||
} else if (
|
} else {
|
||||||
JSON.stringify({
|
const variableOrder = Object.keys(
|
||||||
...configVariable,
|
configVariables[existingConfigVariable.category]
|
||||||
name: existingConfigVariable.name,
|
).indexOf(existingConfigVariable.name);
|
||||||
category: existingConfigVariable.category,
|
|
||||||
value: existingConfigVariable.value,
|
|
||||||
}) != JSON.stringify(existingConfigVariable)
|
|
||||||
) {
|
|
||||||
await prisma.config.update({
|
await prisma.config.update({
|
||||||
where: {
|
where: {
|
||||||
name_category: {
|
name_category: {
|
||||||
@@ -308,8 +392,10 @@ async function migrateConfigVariables() {
|
|||||||
name: existingConfigVariable.name,
|
name: existingConfigVariable.name,
|
||||||
category: existingConfigVariable.category,
|
category: existingConfigVariable.category,
|
||||||
value: existingConfigVariable.value,
|
value: existingConfigVariable.value,
|
||||||
|
order: variableOrder,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
orderMap[existingConfigVariable.category] = variableOrder + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import { Module } from "@nestjs/common";
|
|||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
|
||||||
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
|
import { AppController } from "./app.controller";
|
||||||
|
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||||
import { ConfigModule } from "./config/config.module";
|
import { ConfigModule } from "./config/config.module";
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { FileModule } from "./file/file.module";
|
import { FileModule } from "./file/file.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
|
import { OAuthModule } from "./oauth/oauth.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||||
import { ShareModule } from "./share/share.module";
|
import { ShareModule } from "./share/share.module";
|
||||||
import { UserModule } from "./user/user.module";
|
import { UserModule } from "./user/user.module";
|
||||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
|
||||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
|
||||||
import { AppController } from "./app.controller";
|
|
||||||
import { OAuthModule } from "./oauth/oauth.module";
|
|
||||||
import { CacheModule } from "@nestjs/cache-manager";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,10 +28,12 @@ import { CacheModule } from "@nestjs/cache-manager";
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
ThrottlerModule.forRoot({
|
ThrottlerModule.forRoot([
|
||||||
ttl: 60,
|
{
|
||||||
limit: 100,
|
ttl: 60,
|
||||||
}),
|
limit: 100,
|
||||||
|
},
|
||||||
|
]),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ClamScanModule,
|
ClamScanModule,
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
|
|||||||
@@ -37,15 +37,21 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
async signUp(
|
async signUp(
|
||||||
@Body() dto: AuthRegisterDTO,
|
@Body() dto: AuthRegisterDTO,
|
||||||
|
@Req() { ip }: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("share.allowRegistration"))
|
if (!this.config.get("share.allowRegistration"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto, ip);
|
||||||
|
|
||||||
this.authService.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
@@ -57,13 +63,19 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signIn(
|
async signIn(
|
||||||
@Body() dto: AuthSignInDTO,
|
@Body() dto: AuthSignInDTO,
|
||||||
|
@Req() { ip }: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.signIn(dto);
|
const result = await this.authService.signIn(dto, ip);
|
||||||
|
|
||||||
if (result.accessToken && result.refreshToken) {
|
if (result.accessToken && result.refreshToken) {
|
||||||
this.authService.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
@@ -77,7 +89,12 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signInTotp(
|
async signInTotp(
|
||||||
@Body() dto: AuthSignInTotpDTO,
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
@@ -95,14 +112,24 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("resetPassword/:email")
|
@Post("resetPassword/:email")
|
||||||
@Throttle(5, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
async requestResetPassword(@Param("email") email: string) {
|
async requestResetPassword(@Param("email") email: string) {
|
||||||
this.authService.requestResetPassword(email);
|
await this.authService.requestResetPassword(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("resetPassword")
|
@Post("resetPassword")
|
||||||
@Throttle(5, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||||
return await this.authService.resetPassword(dto.token, dto.password);
|
return await this.authService.resetPassword(dto.token, dto.password);
|
||||||
@@ -145,13 +172,25 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
await this.authService.signOut(request.cookies.access_token);
|
const redirectURI = await this.authService.signOut(
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
request.cookies.access_token,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSecure = this.config.get("general.appUrl").startsWith("https");
|
||||||
|
response.cookie("access_token", "", {
|
||||||
|
maxAge: -1,
|
||||||
|
secure: isSecure,
|
||||||
|
});
|
||||||
response.cookie("refresh_token", "", {
|
response.cookie("refresh_token", "", {
|
||||||
path: "/api/auth/token",
|
path: "/api/auth/token",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
|
secure: isSecure,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof redirectURI === "string") {
|
||||||
|
return { redirectURI: redirectURI.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("totp/enable")
|
@Post("totp/enable")
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { EmailModule } from "src/email/email.module";
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthTotpService } from "./authTotp.service";
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
import { LdapService } from "./ldap.service";
|
||||||
|
import { UserModule } from "../user/user.module";
|
||||||
|
import { OAuthModule } from "../oauth/oauth.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -12,9 +15,11 @@ import { JwtStrategy } from "./strategy/jwt.strategy";
|
|||||||
global: true,
|
global: true,
|
||||||
}),
|
}),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
forwardRef(() => OAuthModule),
|
||||||
|
UserModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
@@ -13,8 +16,12 @@ import * as moment from "moment";
|
|||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { 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 { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
|
import { LdapService } from "./ldap.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -23,9 +30,13 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
|
private ldapService: LdapService,
|
||||||
|
private userService: UserSevice,
|
||||||
|
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||||
|
|
||||||
const hash = dto.password ? await argon.hash(dto.password) : null;
|
const hash = dto.password ? await argon.hash(dto.password) : null;
|
||||||
@@ -35,7 +46,7 @@ export class AuthService {
|
|||||||
email: dto.email,
|
email: dto.email,
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
password: hash,
|
password: hash,
|
||||||
isAdmin: isFirstUser,
|
isAdmin: isAdmin ?? isFirstUser,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,6 +55,7 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
|
this.logger.log(`User ${user.email} signed up from IP ${ip}`);
|
||||||
return { accessToken, refreshToken, user };
|
return { accessToken, refreshToken, user };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PrismaClientKnownRequestError) {
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
@@ -57,29 +69,58 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(dto: AuthSignInDTO) {
|
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||||
if (!dto.email && !dto.username)
|
if (!dto.email && !dto.username) {
|
||||||
throw new BadRequestException("Email or username is required");
|
throw new BadRequestException("Email or username is required");
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
if (!this.config.get("oauth.disablePassword")) {
|
||||||
where: {
|
const user = await this.prisma.user.findFirst({
|
||||||
OR: [{ email: dto.email }, { username: dto.username }],
|
where: {
|
||||||
},
|
OR: [{ email: dto.email }, { username: dto.username }],
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
if (user?.password && (await argon.verify(user.password, dto.password))) {
|
||||||
throw new UnauthorizedException("Wrong email or password");
|
this.logger.log(
|
||||||
|
`Successful password login for user ${user.email} from IP ${ip}`,
|
||||||
|
);
|
||||||
|
return this.generateToken(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.generateToken(user);
|
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, isOAuth = false) {
|
async generateToken(user: User, oauth?: { idToken?: string }) {
|
||||||
// TODO: Make all old loginTokens invalid when a new one is created
|
// TODO: Make all old loginTokens invalid when a new one is created
|
||||||
// Check if the user has TOTP enabled
|
// Check if the user has TOTP enabled
|
||||||
if (
|
if (user.totpVerified && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||||
user.totpVerified &&
|
|
||||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
|
||||||
) {
|
|
||||||
const loginToken = await this.createLoginToken(user.id);
|
const loginToken = await this.createLoginToken(user.id);
|
||||||
|
|
||||||
return { loginToken };
|
return { loginToken };
|
||||||
@@ -87,6 +128,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id,
|
user.id,
|
||||||
|
oauth?.idToken,
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
@@ -94,6 +136,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestResetPassword(email: string) {
|
async requestResetPassword(email: string) {
|
||||||
|
if (this.config.get("oauth.disablePassword"))
|
||||||
|
throw new ForbiddenException("Password sign in is disabled");
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { email },
|
where: { email },
|
||||||
include: { resetPasswordToken: true },
|
include: { resetPasswordToken: true },
|
||||||
@@ -101,6 +146,15 @@ export class AuthService {
|
|||||||
|
|
||||||
if (!user) return;
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete old reset password token
|
// Delete old reset password token
|
||||||
if (user.resetPasswordToken) {
|
if (user.resetPasswordToken) {
|
||||||
await this.prisma.resetPasswordToken.delete({
|
await this.prisma.resetPasswordToken.delete({
|
||||||
@@ -115,10 +169,13 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
this.emailService.sendResetPasswordEmail(user.email, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(token: string, newPassword: string) {
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
if (this.config.get("oauth.disablePassword"))
|
||||||
|
throw new ForbiddenException("Password sign in is disabled");
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { resetPasswordToken: { token } },
|
where: { resetPasswordToken: { token } },
|
||||||
});
|
});
|
||||||
@@ -179,12 +236,54 @@ export class AuthService {
|
|||||||
}) || {};
|
}) || {};
|
||||||
|
|
||||||
if (refreshTokenId) {
|
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
|
await this.prisma.refreshToken
|
||||||
.delete({ where: { id: refreshTokenId } })
|
.delete({ where: { id: refreshTokenId } })
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// Ignore error if refresh token doesn't exist
|
// Ignore error if refresh token doesn't exist
|
||||||
if (e.code != "P2025") throw e;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,9 +302,15 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string, idToken?: string) {
|
||||||
const { id, token } = await this.prisma.refreshToken.create({
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
data: {
|
||||||
|
userId,
|
||||||
|
expiresAt: moment()
|
||||||
|
.add(this.config.get("general.sessionDuration"), "hours")
|
||||||
|
.toDate(),
|
||||||
|
oauthIDToken: idToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { refreshTokenId: id, refreshToken: token };
|
return { refreshTokenId: id, refreshToken: token };
|
||||||
@@ -226,9 +331,11 @@ export class AuthService {
|
|||||||
refreshToken?: string,
|
refreshToken?: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
) {
|
) {
|
||||||
|
const isSecure = this.config.get("general.appUrl").startsWith("https");
|
||||||
if (accessToken)
|
if (accessToken)
|
||||||
response.cookie("access_token", accessToken, {
|
response.cookie("access_token", accessToken, {
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
|
secure: isSecure,
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
||||||
});
|
});
|
||||||
if (refreshToken)
|
if (refreshToken)
|
||||||
@@ -236,7 +343,8 @@ export class AuthService {
|
|||||||
path: "/api/auth/token",
|
path: "/api/auth/token",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
secure: isSecure,
|
||||||
|
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
backend/src/auth/ldap.service.ts
Normal file
105
backend/src/auth/ldap.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { LogLevel } from "@nestjs/common";
|
||||||
|
|
||||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||||
export const DATABASE_URL =
|
export const DATABASE_URL =
|
||||||
@@ -7,3 +9,7 @@ export const CLAMAV_HOST =
|
|||||||
process.env.CLAMAV_HOST ||
|
process.env.CLAMAV_HOST ||
|
||||||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||||
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||||
|
|
||||||
|
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 || ""}`;
|
||||||
@@ -17,13 +17,19 @@ export class EmailService {
|
|||||||
if (!this.config.get("smtp.enabled"))
|
if (!this.config.get("smtp.enabled"))
|
||||||
throw new InternalServerErrorException("SMTP is disabled");
|
throw new InternalServerErrorException("SMTP is disabled");
|
||||||
|
|
||||||
|
const username = this.config.get("smtp.username");
|
||||||
|
const password = this.config.get("smtp.password");
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: this.config.get("smtp.host"),
|
host: this.config.get("smtp.host"),
|
||||||
port: this.config.get("smtp.port"),
|
port: this.config.get("smtp.port"),
|
||||||
secure: this.config.get("smtp.port") == 465,
|
secure: this.config.get("smtp.port") == 465,
|
||||||
auth: {
|
auth:
|
||||||
user: this.config.get("smtp.username"),
|
username || password ? { user: username, pass: password } : undefined,
|
||||||
pass: this.config.get("smtp.password"),
|
tls: {
|
||||||
|
rejectUnauthorized: !this.config.get(
|
||||||
|
"smtp.allowUnauthorizedCertificates",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +117,8 @@ export class EmailService {
|
|||||||
this.config
|
this.config
|
||||||
.get("email.inviteMessage")
|
.get("email.inviteMessage")
|
||||||
.replaceAll("{url}", loginUrl)
|
.replaceAll("{url}", loginUrl)
|
||||||
.replaceAll("{password}", password),
|
.replaceAll("{password}", password)
|
||||||
|
.replaceAll("{email}", recipientEmail),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import * as moment from "moment";
|
|||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private _shareService: ShareService,
|
private _shareService: ShareService,
|
||||||
private _prisma: PrismaService,
|
private _prisma: PrismaService,
|
||||||
|
_config: ConfigService,
|
||||||
) {
|
) {
|
||||||
super(_shareService, _prisma);
|
super(_shareService, _prisma, _config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ClassSerializerInterceptor,
|
ClassSerializerInterceptor,
|
||||||
Logger,
|
Logger,
|
||||||
|
LogLevel,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
@@ -12,10 +13,35 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { ConfigService } from "./config/config.service";
|
import { ConfigService } from "./config/config.service";
|
||||||
import { DATA_DIRECTORY } from "./constants";
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const logLevels = generateNestJsLogLevels();
|
||||||
|
Logger.log(`Showing ${logLevels.join(", ")} messages`);
|
||||||
|
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
logger: logLevels,
|
||||||
|
});
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
@@ -48,7 +74,9 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/swagger", app, document);
|
SwaggerModule.setup("api/swagger", app, document);
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
await app.listen(
|
||||||
|
parseInt(process.env.BACKEND_PORT || process.env.PORT || "8080"),
|
||||||
|
);
|
||||||
|
|
||||||
const logger = new Logger("UnhandledAsyncError");
|
const logger = new Logger("UnhandledAsyncError");
|
||||||
process.on("unhandledRejection", (e) => logger.error(e));
|
process.on("unhandledRejection", (e) => logger.error(e));
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export interface OAuthSignInDto {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerUsername: string;
|
providerUsername: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class OAuthController {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
loginToken?: string;
|
loginToken?: string;
|
||||||
} = await this.oauthService.signIn(user);
|
} = await this.oauthService.signIn(user, request.ip);
|
||||||
if (token.accessToken) {
|
if (token.accessToken) {
|
||||||
this.authService.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { OAuthController } from "./oauth.controller";
|
import { OAuthController } from "./oauth.controller";
|
||||||
import { OAuthService } from "./oauth.service";
|
import { OAuthService } from "./oauth.service";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
@@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
|
|||||||
inject: ["OAUTH_PROVIDERS"],
|
inject: ["OAUTH_PROVIDERS"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: [AuthModule],
|
imports: [forwardRef(() => AuthModule)],
|
||||||
|
exports: [OAuthService],
|
||||||
})
|
})
|
||||||
export class OAuthModule {}
|
export class OAuthModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
@@ -6,15 +6,19 @@ import { ConfigService } from "../config/config.service";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||||
|
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthService {
|
export class OAuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private auth: AuthService,
|
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
|
||||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||||
|
@Inject("OAUTH_PROVIDERS")
|
||||||
|
private oAuthProviders: Record<string, OAuthProvider<unknown>>,
|
||||||
) {}
|
) {}
|
||||||
|
private readonly logger = new Logger(OAuthService.name);
|
||||||
|
|
||||||
available(): string[] {
|
available(): string[] {
|
||||||
return this.platforms
|
return this.platforms
|
||||||
@@ -26,6 +30,18 @@ export class OAuthService {
|
|||||||
.map(([platform, _]) => platform);
|
.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) {
|
async status(user: User) {
|
||||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
||||||
select: {
|
select: {
|
||||||
@@ -39,21 +55,25 @@ export class OAuthService {
|
|||||||
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
|
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(user: OAuthSignInDto) {
|
async signIn(user: OAuthSignInDto, ip: string) {
|
||||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||||
where: {
|
where: {
|
||||||
provider: user.provider,
|
provider: user.provider,
|
||||||
providerUserId: user.providerId,
|
providerUserId: user.providerId,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (oauthUser) {
|
if (oauthUser) {
|
||||||
return this.auth.generateToken(oauthUser.user, true);
|
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);
|
return this.signUp(user, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
async link(
|
async link(
|
||||||
@@ -102,9 +122,11 @@ export class OAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAvailableUsername(email: string) {
|
private async getAvailableUsername(preferredUsername: string) {
|
||||||
// only remove + and - from email for now (maybe not enough)
|
// Only keep letters, numbers, dots, and underscores. Truncate to 20 characters.
|
||||||
let username = email.split("@")[0].replace(/[+-]/g, "").substring(0, 20);
|
let username = preferredUsername
|
||||||
|
.replace(/[^a-zA-Z0-9._]/g, "")
|
||||||
|
.substring(0, 20);
|
||||||
while (true) {
|
while (true) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -119,7 +141,7 @@ export class OAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async signUp(user: OAuthSignInDto) {
|
private async signUp(user: OAuthSignInDto, ip: string) {
|
||||||
// register
|
// register
|
||||||
if (!this.config.get("oauth.allowRegistration")) {
|
if (!this.config.get("oauth.allowRegistration")) {
|
||||||
throw new ErrorPageException("no_user", "/auth/signIn", [
|
throw new ErrorPageException("no_user", "/auth/signIn", [
|
||||||
@@ -148,14 +170,19 @@ export class OAuthService {
|
|||||||
userId: existingUser.id,
|
userId: existingUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.auth.generateToken(existingUser, true);
|
await this.updateIsAdmin(user);
|
||||||
|
return this.auth.generateToken(existingUser, { idToken: user.idToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.auth.signUp({
|
const result = await this.auth.signUp(
|
||||||
email: user.email,
|
{
|
||||||
username: await this.getAvailableUsername(user.email),
|
email: user.email,
|
||||||
password: null,
|
username: await this.getAvailableUsername(user.providerUsername),
|
||||||
});
|
password: null,
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
user.isAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
await this.prisma.oAuthUser.create({
|
await this.prisma.oAuthUser.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -168,4 +195,16 @@ export class OAuthService {
|
|||||||
|
|
||||||
return result;
|
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,5 +1,4 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import fetch from "node-fetch";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
@@ -52,7 +51,7 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const token: DiscordToken = await res.json();
|
const token = (await res.json()) as DiscordToken;
|
||||||
return {
|
return {
|
||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
refreshToken: token.refresh_token,
|
refreshToken: token.refresh_token,
|
||||||
@@ -82,12 +81,17 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
if (guild) {
|
if (guild) {
|
||||||
await this.checkLimitedGuild(token, guild);
|
await this.checkLimitedGuild(token, guild);
|
||||||
}
|
}
|
||||||
|
const limitedUsers = this.config.get("oauth.discord-limitedUsers");
|
||||||
|
if (limitedUsers) {
|
||||||
|
await this.checkLimitedUsers(user, limitedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
providerId: user.id,
|
providerId: user.id,
|
||||||
providerUsername: user.global_name ?? user.username,
|
providerUsername: user.global_name ?? user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
idToken: `discord:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +106,16 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
});
|
});
|
||||||
const guilds = (await res.json()) as DiscordPartialGuild[];
|
const guilds = (await res.json()) as DiscordPartialGuild[];
|
||||||
if (!guilds.some((guild) => guild.id === guildId)) {
|
if (!guilds.some((guild) => guild.id === guildId)) {
|
||||||
throw new ErrorPageException("discord_guild_permission_denied");
|
throw new ErrorPageException("user_not_allowed");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw new ErrorPageException("discord_guild_permission_denied");
|
throw new ErrorPageException("user_not_allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLimitedUsers(user: DiscordUser, userIds: string) {
|
||||||
|
if (!userIds.split(",").includes(user.id)) {
|
||||||
|
throw new ErrorPageException("user_not_allowed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Logger } from "@nestjs/common";
|
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||||
import fetch from "node-fetch";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Cache } from "cache-manager";
|
import { Cache } from "cache-manager";
|
||||||
|
import * as jmespath from "jmespath";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||||
|
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||||
|
|
||||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||||
protected discoveryUri: string;
|
protected discoveryUri: string;
|
||||||
@@ -25,7 +25,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
protected cache: Cache,
|
protected cache: Cache,
|
||||||
) {
|
) {
|
||||||
this.discoveryUri = this.getDiscoveryUri();
|
this.discoveryUri = this.getDiscoveryUri();
|
||||||
this.config.addListener("update", (key: string, _: unknown) => {
|
this.config.addListener("update", (key: string) => {
|
||||||
if (this.keyOfConfigUpdateEvents.includes(key)) {
|
if (this.keyOfConfigUpdateEvents.includes(key)) {
|
||||||
this.deinit();
|
this.deinit();
|
||||||
this.discoveryUri = this.getDiscoveryUri();
|
this.discoveryUri = this.getDiscoveryUri();
|
||||||
@@ -94,7 +94,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
redirect_uri: this.getRedirectUri(),
|
redirect_uri: this.getRedirectUri(),
|
||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
const token: OidcToken = await res.json();
|
const token = (await res.json()) as OidcToken;
|
||||||
return {
|
return {
|
||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
expiresIn: token.expires_in,
|
expiresIn: token.expires_in,
|
||||||
@@ -109,9 +109,20 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
token: OAuthToken<OidcToken>,
|
token: OAuthToken<OidcToken>,
|
||||||
query: OAuthCallbackDto,
|
query: OAuthCallbackDto,
|
||||||
claim?: string,
|
claim?: string,
|
||||||
|
roleConfig?: {
|
||||||
|
path?: string;
|
||||||
|
generalAccess?: string;
|
||||||
|
adminAccess?: string;
|
||||||
|
},
|
||||||
): Promise<OAuthSignInDto> {
|
): Promise<OAuthSignInDto> {
|
||||||
const idTokenData = this.decodeIdToken(token.idToken);
|
const idTokenData = this.decodeIdToken(token.idToken);
|
||||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
|
||||||
|
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 key = `oauth-${this.name}-nonce-${query.state}`;
|
||||||
const nonce = await this.cache.get(key);
|
const nonce = await this.cache.get(key);
|
||||||
@@ -125,9 +136,47 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
|
|
||||||
const username = claim
|
const username = claim
|
||||||
? idTokenData[claim]
|
? idTokenData[claim]
|
||||||
: idTokenData.name ||
|
: idTokenData.preferred_username ||
|
||||||
idTokenData.nickname ||
|
idTokenData.name ||
|
||||||
idTokenData.preferred_username;
|
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) {
|
if (!username) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -147,6 +196,8 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
email: idTokenData.email,
|
email: idTokenData.email,
|
||||||
providerId: idTokenData.sub,
|
providerId: idTokenData.sub,
|
||||||
providerUsername: username,
|
providerUsername: username,
|
||||||
|
...(isAdmin !== undefined && { isAdmin }),
|
||||||
|
idToken: `${this.name}:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +210,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
: Date.now() + 1000 * 60 * 60 * 24;
|
: Date.now() + 1000 * 60 * 60 * 24;
|
||||||
this.configuration = {
|
this.configuration = {
|
||||||
expires,
|
expires,
|
||||||
data: await res.json(),
|
data: (await res.json()) as OidcConfiguration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +252,8 @@ export interface OidcConfiguration {
|
|||||||
id_token_signing_alg_values_supported: string[];
|
id_token_signing_alg_values_supported: string[];
|
||||||
scopes_supported?: string[];
|
scopes_supported?: string[];
|
||||||
claims_supported?: string[];
|
claims_supported?: string[];
|
||||||
|
frontchannel_logout_supported?: boolean;
|
||||||
|
end_session_endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OidcJwk {
|
export interface OidcJwk {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import fetch from "node-fetch";
|
|
||||||
import { ConfigService } from "../../config/config.service";
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
@@ -38,7 +37,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const token: GitHubToken = await res.json();
|
const token = (await res.json()) as GitHubToken;
|
||||||
return {
|
return {
|
||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
tokenType: token.token_type,
|
tokenType: token.token_type,
|
||||||
@@ -62,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
|||||||
providerId: user.id.toString(),
|
providerId: user.id.toString(),
|
||||||
providerUsername: user.name ?? user.login,
|
providerUsername: user.name ?? user.login,
|
||||||
email,
|
email,
|
||||||
|
idToken: `github:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export class OidcProvider extends GenericOidcProvider {
|
|||||||
_?: string,
|
_?: string,
|
||||||
): Promise<OAuthSignInDto> {
|
): Promise<OAuthSignInDto> {
|
||||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
||||||
return super.getUserInfo(token, query, claim);
|
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,9 +1,11 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { DATABASE_URL } from "../constants";
|
import { DATABASE_URL } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient {
|
export class PrismaService extends PrismaClient {
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
datasources: {
|
datasources: {
|
||||||
@@ -12,6 +14,6 @@ export class PrismaService extends PrismaClient {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
super.$connect().then(() => console.info("Connected to the database"));
|
super.$connect().then(() => this.logger.log("Connected to the database"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ export class CreateReverseShareDTO {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(1000)
|
@Max(1000)
|
||||||
maxUseCount: number;
|
maxUseCount: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
simplified: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
publicAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export class ReverseShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
simplified: boolean;
|
||||||
|
|
||||||
from(partial: Partial<ReverseShareDTO>) {
|
from(partial: Partial<ReverseShareDTO>) {
|
||||||
return plainToClass(ReverseShareDTO, partial, {
|
return plainToClass(ReverseShareDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
|||||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||||
shares: Omit<
|
shares: Omit<
|
||||||
MyShareDTO,
|
MyShareDTO,
|
||||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
"recipients" | "files" | "from" | "fromList" | "hasPassword" | "size"
|
||||||
>[];
|
>[];
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export class ReverseShareController {
|
|||||||
return { token, link };
|
return { token, link };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(20, 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@Get(":reverseShareToken")
|
@Get(":reverseShareToken")
|
||||||
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||||
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export class ReverseShareService {
|
|||||||
remainingUses: data.maxUseCount,
|
remainingUses: data.maxUseCount,
|
||||||
maxShareSize: data.maxShareSize,
|
maxShareSize: data.maxShareSize,
|
||||||
sendEmailNotification: data.sendEmailNotification,
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
|
simplified: data.simplified,
|
||||||
|
publicAccess: data.publicAccess,
|
||||||
creatorId,
|
creatorId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
27
backend/src/share/dto/adminShare.dto.ts
Normal file
27
backend/src/share/dto/adminShare.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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,6 +18,10 @@ export class CreateShareDTO {
|
|||||||
@Length(3, 50)
|
@Length(3, 50)
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@Length(3, 30)
|
||||||
|
@IsOptional()
|
||||||
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
expiration: string;
|
expiration: string;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
expiration: Date;
|
expiration: Date;
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
hasPassword: boolean;
|
hasPassword: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
size: number;
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|||||||
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,8 @@ export class CreateShareGuard extends JwtGuard {
|
|||||||
|
|
||||||
if (!reverseShareTokenId) return false;
|
if (!reverseShareTokenId) return false;
|
||||||
|
|
||||||
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
const isReverseShareTokenValid =
|
||||||
reverseShareTokenId,
|
await this.reverseShareService.isValid(reverseShareTokenId);
|
||||||
);
|
|
||||||
|
|
||||||
return isReverseShareTokenValid;
|
return isReverseShareTokenValid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareOwnerGuard extends JwtGuard {
|
export class ShareOwnerGuard extends JwtGuard {
|
||||||
@@ -34,10 +34,20 @@ export class ShareOwnerGuard extends JwtGuard {
|
|||||||
|
|
||||||
if (!share) throw new NotFoundException("Share not found");
|
if (!share) throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
|
// Run the JWTGuard to set the user
|
||||||
|
await super.canActivate(context);
|
||||||
|
const user = request.user as User;
|
||||||
|
|
||||||
|
// If the user is an admin, allow access
|
||||||
|
if (user?.isAdmin) return true;
|
||||||
|
|
||||||
|
// If it's a anonymous share, allow access
|
||||||
if (!share.creatorId) return true;
|
if (!share.creatorId) return true;
|
||||||
|
|
||||||
if (!(await super.canActivate(context))) return false;
|
// If not signed in, deny access
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
return share.creatorId == (request.user as User).id;
|
// If the user is the creator of the share, allow access
|
||||||
|
return share.creatorId == user.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -9,13 +8,19 @@ import { Request } from "express";
|
|||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ShareService } from "src/share/share.service";
|
import { ShareService } from "src/share/share.service";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard extends JwtGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
configService: ConfigService,
|
||||||
|
) {
|
||||||
|
super(configService);
|
||||||
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
@@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true },
|
include: { security: true, reverseShare: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -53,6 +58,22 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
"share_token_required",
|
"share_token_required",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run the JWTGuard to set the user
|
||||||
|
await super.canActivate(context);
|
||||||
|
const user = request.user as User;
|
||||||
|
|
||||||
|
// Only the creator and reverse share creator can access the reverse share if it's not public
|
||||||
|
if (
|
||||||
|
share.reverseShare &&
|
||||||
|
!share.reverseShare.publicAccess &&
|
||||||
|
share.creatorId !== user?.id &&
|
||||||
|
share.reverseShare.creatorId !== user?.id
|
||||||
|
)
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only reverse share creator can access this share",
|
||||||
|
"private_share",
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
import * as moment from "moment";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { AdminShareDTO } from "./dto/adminShare.dto";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
import { MyShareDTO } from "./dto/myShare.dto";
|
import { MyShareDTO } from "./dto/myShare.dto";
|
||||||
import { ShareDTO } from "./dto/share.dto";
|
import { ShareDTO } from "./dto/share.dto";
|
||||||
@@ -25,9 +29,19 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
|||||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
|
import { CompletedShareDTO } from "./dto/shareComplete.dto";
|
||||||
@Controller("shares")
|
@Controller("shares")
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(private shareService: ShareService) {}
|
constructor(
|
||||||
|
private shareService: ShareService,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("all")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async getAllShares() {
|
||||||
|
return new AdminShareDTO().fromList(await this.shareService.getShares());
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@@ -73,7 +87,7 @@ export class ShareController {
|
|||||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new ShareDTO().from(
|
return new CompletedShareDTO().from(
|
||||||
await this.shareService.complete(id, reverse_share_token),
|
await this.shareService.complete(id, reverse_share_token),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,26 +100,40 @@ export class ShareController {
|
|||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@UseGuards(ShareOwnerGuard)
|
@UseGuards(ShareOwnerGuard)
|
||||||
async remove(@Param("id") id: string) {
|
async remove(@Param("id") id: string, @GetUser() user: User) {
|
||||||
await this.shareService.remove(id);
|
const isDeleterAdmin = user?.isAdmin === true;
|
||||||
|
await this.shareService.remove(id, isDeleterAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 10,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@Get("isShareIdAvailable/:id")
|
@Get("isShareIdAvailable/:id")
|
||||||
async isShareIdAvailable(@Param("id") id: string) {
|
async isShareIdAvailable(@Param("id") id: string) {
|
||||||
return this.shareService.isShareIdAvailable(id);
|
return this.shareService.isShareIdAvailable(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle(20, 5 * 60)
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 20,
|
||||||
|
ttl: 5 * 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
@UseGuards(ShareTokenSecurity)
|
@UseGuards(ShareTokenSecurity)
|
||||||
@Post(":id/token")
|
@Post(":id/token")
|
||||||
async getShareToken(
|
async getShareToken(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() body: SharePasswordDto,
|
@Body() body: SharePasswordDto,
|
||||||
) {
|
) {
|
||||||
const token = await this.shareService.getShareToken(id, body.password);
|
const token = await this.shareService.getShareToken(id, body.password);
|
||||||
|
|
||||||
|
this.clearShareTokenCookies(request, response);
|
||||||
response.cookie(`share_${id}_token`, token, {
|
response.cookie(`share_${id}_token`, token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -113,4 +141,32 @@ export class ShareController {
|
|||||||
|
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the 10 most recent share token cookies and deletes the rest and all expired ones
|
||||||
|
*/
|
||||||
|
private clearShareTokenCookies(request: Request, response: Response) {
|
||||||
|
const shareTokenCookies = Object.entries(request.cookies)
|
||||||
|
.filter(([key]) => key.startsWith("share_") && key.endsWith("_token"))
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
payload: this.jwtService.decode(value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expiredTokens = shareTokenCookies.filter(
|
||||||
|
(cookie) => cookie.payload.exp < moment().unix(),
|
||||||
|
);
|
||||||
|
const validTokens = shareTokenCookies.filter(
|
||||||
|
(cookie) => cookie.payload.exp >= moment().unix(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expiredTokens.forEach((cookie) => response.clearCookie(cookie.key));
|
||||||
|
|
||||||
|
if (validTokens.length > 10) {
|
||||||
|
validTokens
|
||||||
|
.sort((a, b) => a.payload.exp - b.payload.exp)
|
||||||
|
.slice(0, -10)
|
||||||
|
.forEach((cookie) => response.clearCookie(cookie.key));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ShareService } from "./share.service";
|
|||||||
imports: [
|
imports: [
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
ClamScanModule,
|
forwardRef(() => ClamScanModule),
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
forwardRef(() => FileModule),
|
forwardRef(() => FileModule),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
|
||||||
import { Share, User } from "@prisma/client";
|
import { Share, User } from "@prisma/client";
|
||||||
import * as archiver from "archiver";
|
import * as archiver from "archiver";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
@@ -46,9 +46,8 @@ export class ShareService {
|
|||||||
let expirationDate: Date;
|
let expirationDate: Date;
|
||||||
|
|
||||||
// If share is created by a reverse share token override the expiration date
|
// If share is created by a reverse share token override the expiration date
|
||||||
const reverseShare = await this.reverseShareService.getByToken(
|
const reverseShare =
|
||||||
reverseShareToken,
|
await this.reverseShareService.getByToken(reverseShareToken);
|
||||||
);
|
|
||||||
if (reverseShare) {
|
if (reverseShare) {
|
||||||
expirationDate = reverseShare.shareExpiration;
|
expirationDate = reverseShare.shareExpiration;
|
||||||
} else {
|
} else {
|
||||||
@@ -160,11 +159,12 @@ export class ShareService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const notifyReverseShareCreator = share.reverseShare
|
||||||
share.reverseShare &&
|
? this.config.get("smtp.enabled") &&
|
||||||
this.config.get("smtp.enabled") &&
|
share.reverseShare.sendEmailNotification
|
||||||
share.reverseShare.sendEmailNotification
|
: undefined;
|
||||||
) {
|
|
||||||
|
if (notifyReverseShareCreator) {
|
||||||
await this.emailService.sendMailToReverseShareCreator(
|
await this.emailService.sendMailToReverseShareCreator(
|
||||||
share.reverseShare.creator.email,
|
share.reverseShare.creator.email,
|
||||||
share.id,
|
share.id,
|
||||||
@@ -181,10 +181,15 @@ export class ShareService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.share.update({
|
const updatedShare = await this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { uploadLocked: true },
|
data: { uploadLocked: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updatedShare,
|
||||||
|
notifyReverseShareCreator,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertComplete(id: string) {
|
async revertComplete(id: string) {
|
||||||
@@ -194,6 +199,22 @@ export class ShareService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getSharesByUser(userId: string) {
|
async getSharesByUser(userId: string) {
|
||||||
const shares = await this.prisma.share.findMany({
|
const shares = await this.prisma.share.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -214,6 +235,7 @@ export class ShareService {
|
|||||||
return shares.map((share) => {
|
return shares.map((share) => {
|
||||||
return {
|
return {
|
||||||
...share,
|
...share,
|
||||||
|
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
||||||
recipients: share.recipients.map((recipients) => recipients.email),
|
recipients: share.recipients.map((recipients) => recipients.email),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -223,7 +245,11 @@ export class ShareService {
|
|||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: {
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
},
|
||||||
creator: true,
|
creator: true,
|
||||||
security: true,
|
security: true,
|
||||||
},
|
},
|
||||||
@@ -251,13 +277,14 @@ export class ShareService {
|
|||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(shareId: string) {
|
async remove(shareId: string, isDeleterAdmin = false) {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!share) throw new NotFoundException("Share not found");
|
if (!share) throw new NotFoundException("Share not found");
|
||||||
if (!share.creatorId)
|
|
||||||
|
if (!share.creatorId && !isDeleterAdmin)
|
||||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||||
|
|
||||||
await this.fileService.deleteAllFiles(shareId);
|
await this.fileService.deleteAllFiles(shareId);
|
||||||
@@ -311,15 +338,21 @@ export class ShareService {
|
|||||||
const { expiration } = await this.prisma.share.findUnique({
|
const { expiration } = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
});
|
});
|
||||||
return this.jwtService.sign(
|
|
||||||
{
|
const tokenPayload = {
|
||||||
shareId,
|
shareId,
|
||||||
},
|
iat: moment().unix(),
|
||||||
{
|
};
|
||||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyShareToken(shareId: string, token: string) {
|
async verifyShareToken(shareId: string, token: string) {
|
||||||
|
|||||||
@@ -25,16 +25,23 @@ export class UserDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
isLdap: boolean;
|
||||||
|
|
||||||
|
ldapDN?: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
totpVerified: boolean;
|
totpVerified: boolean;
|
||||||
|
|
||||||
from(partial: Partial<UserDTO>) {
|
from(partial: Partial<UserDTO>) {
|
||||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
const result = plainToClass(UserDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
result.isLdap = partial.ldapDN?.length > 0;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
fromList(partial: Partial<UserDTO>[]) {
|
fromList(partial: Partial<UserDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) => this.from(part));
|
||||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
|||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
import { UserDTO } from "./dto/user.dto";
|
||||||
import { UserSevice } from "./user.service";
|
import { UserSevice } from "./user.service";
|
||||||
|
import { ConfigService } from "../config/config.service";
|
||||||
|
|
||||||
@Controller("users")
|
@Controller("users")
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private userService: UserSevice) {}
|
constructor(
|
||||||
|
private userService: UserSevice,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
// Own user operations
|
// Own user operations
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@@ -49,11 +53,17 @@ export class UserController {
|
|||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
const isSecure = this.config.get("general.appUrl").startsWith("https");
|
||||||
|
|
||||||
|
response.cookie("access_token", "accessToken", {
|
||||||
|
maxAge: -1,
|
||||||
|
secure: isSecure,
|
||||||
|
});
|
||||||
response.cookie("refresh_token", "", {
|
response.cookie("refresh_token", "", {
|
||||||
path: "/api/auth/token",
|
path: "/api/auth/token",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
|
secure: isSecure,
|
||||||
});
|
});
|
||||||
return new UserDTO().from(await this.userService.delete(user.id));
|
return new UserDTO().from(await this.userService.delete(user.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ import { FileModule } from "src/file/file.module";
|
|||||||
imports: [EmailModule, FileModule],
|
imports: [EmailModule, FileModule],
|
||||||
providers: [UserSevice],
|
providers: [UserSevice],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
|
exports: [UserSevice],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
@@ -7,13 +7,20 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
import { FileService } from "../file/file.service";
|
import { FileService } from "../file/file.service";
|
||||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
|
import { ConfigService } from "../config/config.service";
|
||||||
|
import { Entry } from "ldapts";
|
||||||
|
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||||
|
import { inspect } from "util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
|
private readonly logger = new Logger(UserSevice.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
@@ -88,4 +95,124 @@ export class UserSevice {
|
|||||||
|
|
||||||
return await this.prisma.user.delete({ where: { 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,7 +432,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -626,7 +626,7 @@
|
|||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
||||||
"});",
|
"});",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
clamav:
|
clamav:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
pingvin-share:
|
pingvin-share:
|
||||||
image: stonith404/pingvin-share
|
image: stonith404/pingvin-share
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
|
environment:
|
||||||
|
- TRUST_PROXY=false # Set to true if a reverse proxy is in front of the container
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/opt/app/backend/data"
|
- "./data:/opt/app/backend/data"
|
||||||
- "./data/images:/opt/app/frontend/public/img"
|
- "./data/images:/opt/app/frontend/public/img"
|
||||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
|
||||||
# depends_on:
|
# To add ClamAV, to scan your shares for malicious files,
|
||||||
# clamav:
|
# see https://stonith404.github.io/pingvin-share/setup/integrations/#clamav-docker-only
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
_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.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
_选择合适的语言阅读: [西班牙语](/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` 来执行系统测试
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# <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), [日本語](/docs/README.ja-jp.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 realizada por No Solo Hacking (español)](https://www.youtube.com/watch?v=ocd4EpLTYkU)
|
|
||||||
- [Reseña por DB Tech (inglés)](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.
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_READMEを別の言語で読む: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.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は、早期段階であり、バグが含まれている場合があります。
|
|
||||||
|
|
||||||
### Dockerでインストール (おすすめ)
|
|
||||||
|
|
||||||
1. `docker-compose.yml`ファイルをダウンロード
|
|
||||||
2. `docker-compose up -d`を実行
|
|
||||||
|
|
||||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
|
||||||
|
|
||||||
### スタンドアローンインストール
|
|
||||||
|
|
||||||
必要なツール:
|
|
||||||
|
|
||||||
- [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`)
|
|
||||||
|
|
||||||
# バックエンドを開始
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
|
||||||
|
|
||||||
#フロントエンドを開始
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
|
||||||
```
|
|
||||||
|
|
||||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
|
||||||
|
|
||||||
### 連携機能
|
|
||||||
|
|
||||||
#### ClamAV (Dockerのみ)
|
|
||||||
|
|
||||||
ClamAVは、共有されたファイルをスキャンし、感染したファイルを見つけた場合に削除するために使用されます。
|
|
||||||
|
|
||||||
1. ClamAVコンテナをDocker Composeの定義ファイル(`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)をご確認ください。
|
|
||||||
|
|
||||||
### 追加情報
|
|
||||||
|
|
||||||
- [Synology NASへのインストール方法](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
|
||||||
|
|
||||||
### 新しいバージョンへのアップグレード
|
|
||||||
|
|
||||||
Pingvin Shareは早期段階のため、アップグレード前に必ずリリースノートを確認して、アップグレードしても問題ないかどうかご確認ください。
|
|
||||||
|
|
||||||
#### Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
#### スタンドアローン
|
|
||||||
|
|
||||||
1. アプリを停止する
|
|
||||||
```bash
|
|
||||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
|
||||||
```
|
|
||||||
2. `git clone`のステップを除いて、[インストールガイド](#stand-alone-installation)をくり返してください。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pingvin-share
|
|
||||||
|
|
||||||
# 最新バージョンをチェックアウト
|
|
||||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
|
||||||
|
|
||||||
# バックエンドを開始
|
|
||||||
cd backend
|
|
||||||
npm run build
|
|
||||||
pm2 restart pingvin-share-backend
|
|
||||||
|
|
||||||
#フロントエンドを開始
|
|
||||||
cd ../frontend
|
|
||||||
npm run build
|
|
||||||
pm2 restart pingvin-share-frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### 設定
|
|
||||||
|
|
||||||
管理者のダッシュボード内の「設定」ページから、Pingvin Shareをカスタマイズできます。
|
|
||||||
|
|
||||||
#### 環境変数
|
|
||||||
|
|
||||||
インストール時の特定の設定で、環境変数を使用できます。次の環境変数が使用可能です:
|
|
||||||
|
|
||||||
##### バックエンド
|
|
||||||
|
|
||||||
| 変数名 | デフォルト値 | 説明 |
|
|
||||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
|
||||||
| `PORT` | `8080` | バックエンドがリッスンするポート番号 |
|
|
||||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | SQLiteのURL |
|
|
||||||
| `DATA_DIRECTORY` | `./data` | データを保管するディレクトリ |
|
|
||||||
| `CLAMAV_HOST` | `127.0.0.1` | ClamAVサーバーのIPアドレス |
|
|
||||||
| `CLAMAV_PORT` | `3310` | ClamAVサーバーのポート番号 |
|
|
||||||
|
|
||||||
##### フロントエンド
|
|
||||||
|
|
||||||
| 変数名 | デフォルト値 | 説明 |
|
|
||||||
| --------- | ----------------------- | ---------------------------------------- |
|
|
||||||
| `PORT` | `3000` | フロントエンドがリッスンするポート番号 |
|
|
||||||
| `API_URL` | `http://localhost:8080` | フロントエンドからアクセスするバックエンドへのURL |
|
|
||||||
|
|
||||||
## 🖤 コントリビュート
|
|
||||||
|
|
||||||
### 翻訳
|
|
||||||
|
|
||||||
Pingvin Shareをあなたが使用している言語に翻訳するお手伝いを募集しています。
|
|
||||||
[Crowdin](https://crowdin.com/project/pingvin-share)上で、簡単にPingvin Shareの翻訳作業への参加が可能です。
|
|
||||||
|
|
||||||
あなたの言語がありませんか? 気軽に[リクエスト](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E)してください。
|
|
||||||
|
|
||||||
翻訳中に問題がありましたか? [ローカライズに関するディスカッション](https://github.com/stonith404/pingvin-share/discussions/198)に是非参加してください。
|
|
||||||
|
|
||||||
### プロジェクト
|
|
||||||
|
|
||||||
Pingvin Shareへのコントリビュートをいつでもお待ちしています! [コントリビューションガイド](/CONTRIBUTING.md)を確認して、是非参加してください。
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# <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), [日本语](/docs/README.ja-jp.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) 来提交你的贡献
|
|
||||||
3
docs/babel.config.js
Normal file
3
docs/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||||
|
};
|
||||||
91
docs/docs/help-out/contribute.md
Normal file
91
docs/docs/help-out/contribute.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
||||||
|
|
||||||
|
## Submit a Pull Request
|
||||||
|
|
||||||
|
Before you submit the pull request for review please ensure that
|
||||||
|
|
||||||
|
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||||
|
|
||||||
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
|
example:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): add password protection
|
||||||
|
```
|
||||||
|
|
||||||
|
When `TYPE` can be:
|
||||||
|
|
||||||
|
- **feat** - is a new feature
|
||||||
|
- **doc** - documentation only changes
|
||||||
|
- **fix** - a bug fix
|
||||||
|
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||||
|
|
||||||
|
- Your pull request has a detailed description
|
||||||
|
- You run `npm run format` to format the code
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
|
||||||
|
|
||||||
|
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
|
||||||
|
|
||||||
|
2. Clone your fork to your machine with `git clone`
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/[your_username]/pingvin-share
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Work - commit - repeat
|
||||||
|
|
||||||
|
4. Push changes to GitHub
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git push origin [name_of_your_new_branch]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Submit your changes for review
|
||||||
|
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
||||||
|
6. Start a Pull Request
|
||||||
|
7. Now submit the pull request and click on `Create pull request`.
|
||||||
|
8. Get a code review approval/reject
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Setup project
|
||||||
|
|
||||||
|
Pingvin Share consists of a frontend and a backend.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
1. Open the `backend` folder
|
||||||
|
2. Install the dependencies with `npm install`
|
||||||
|
3. Push the database schema to the database by running `npx prisma db push`
|
||||||
|
4. Seed the database with `npx prisma db seed`
|
||||||
|
5. Start the backend with `npm run dev`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
1. Start the backend first
|
||||||
|
2. Open the `frontend` folder
|
||||||
|
3. Install the dependencies with `npm install`
|
||||||
|
4. Start the frontend with `npm run dev`
|
||||||
|
|
||||||
|
You're all set!
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
At the moment we only have system tests for the backend. To run these tests, run `npm run test:system` in the backend folder.
|
||||||
8
docs/docs/help-out/translate.md
Normal file
8
docs/docs/help-out/translate.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Translating
|
||||||
|
|
||||||
|
You can help to translate Pingvin Share into your language.
|
||||||
|
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
|
||||||
|
|
||||||
|
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
|
||||||
|
|
||||||
|
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
|
||||||
24
docs/docs/introduction.md
Normal file
24
docs/docs/introduction.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
id: introduction
|
||||||
|
---
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Share files using a link
|
||||||
|
- Unlimited file size (restricted only by disk space)
|
||||||
|
- Set an expiration date for shares
|
||||||
|
- Secure shares with visitor limits and passwords
|
||||||
|
- Email recipients
|
||||||
|
- Integration with ClamAV for security scans
|
||||||
|
|
||||||
|
And more!
|
||||||
|
|
||||||
|
## Get to know Pingvin Share
|
||||||
|
|
||||||
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
56
docs/docs/setup/configuration.md
Normal file
56
docs/docs/setup/configuration.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
id: configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
You can customize Pingvin Share by going to the configuration page in your admin dashboard `/admin/config`.
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
The **General** Tab will let you customize your Pingvin Share instance to your liking.
|
||||||
|
|
||||||
|
### App name
|
||||||
|
|
||||||
|
To change the name of your instance, insert any text into `App name`.
|
||||||
|
|
||||||
|
### App URL
|
||||||
|
|
||||||
|
To make your App available trough your own **domain**, insert your specific domain and also subdomain if needed. Add an `https://` if you have an SSL certificate installed. If this is not the case, use `http://`.
|
||||||
|
|
||||||
|
### Show home page
|
||||||
|
|
||||||
|
If you don't like the **home page** Pingvin Share provides and you just want the upload tab to be the main page, toggle this to `true`.
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
|
||||||
|
Not only you can change your instances name, but also the logo it shows everywhere. To do that, upload an image as `png` with a 1:1 aspect ratio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `BACKEND_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` or `clamav` when running with Docker | The IP address of the ClamAV server. See the [ClamAV docs](integrations.md#clamav) for more information. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
#### Reverse Proxy (inside the Docker container)
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| ------------- | ------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `TRUST_PROXY` | `false` | Whether Pingvin Share is behind a reverse proxy. If set to `true`, the `X-Forwarded-For` header is trusted. |
|
||||||
45
docs/docs/setup/installation.md
Normal file
45
docs/docs/setup/installation.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
id: installation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
|
1. Download the `docker-compose.yml` file
|
||||||
|
2. Run `docker compose up -d`
|
||||||
|
|
||||||
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Stand-alone Installation
|
||||||
|
|
||||||
|
Required tools:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||||
|
pm2 start npm --name "pingvin-share-frontend" -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./reverse-proxy/Caddyfile`.
|
||||||
|
|
||||||
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
42
docs/docs/setup/integrations.md
Normal file
42
docs/docs/setup/integrations.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
id: integrations
|
||||||
|
---
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
|
||||||
|
## ClamAV
|
||||||
|
|
||||||
|
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||||
|
|
||||||
|
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
If you are already running ClamAV elsewhere, you can specify the `CLAMAV_HOST` environment variable to point to that instance.
|
||||||
|
|
||||||
|
Else you have to add the ClamAV container to the Pingvin Share Docker Compose stack:
|
||||||
|
|
||||||
|
1. Add the ClamAV container to the Docker Compose stack and start the container.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
services:
|
||||||
|
pingvin-share:
|
||||||
|
image: stonith404/pingvin-share
|
||||||
|
...
|
||||||
|
+ depends_on:
|
||||||
|
+ clamav:
|
||||||
|
+ condition: service_healthy
|
||||||
|
|
||||||
|
+ clamav:
|
||||||
|
+ restart: unless-stopped
|
||||||
|
+ image: clamav/clamav
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
### Stand-Alone
|
||||||
|
|
||||||
|
1. Install ClamAV
|
||||||
|
2. Specify the `CLAMAV_HOST` environment variable for the backend and restart the Pingvin Share backend.
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
id: oauth2login
|
||||||
|
---
|
||||||
|
|
||||||
# OAuth 2 Login Guide
|
# OAuth 2 Login Guide
|
||||||
|
|
||||||
## Config Built-in OAuth 2 Providers
|
## Config Built-in OAuth 2 Providers
|
||||||
@@ -24,8 +28,7 @@ Redirect URL: `https://<your-domain>/api/oauth/callback/google`
|
|||||||
|
|
||||||
Please follow the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) to register an application.
|
Please follow the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) to register an application.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT] > **Microsoft Tenant** you set in the admin panel must match the **supported account types** you set in the Microsoft Entra admin center, otherwise the OAuth login will not work. Refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri) for more details.
|
||||||
> **Microsoft Tenant** you set in the admin panel must match the **supported account types** you set in the Microsoft Entra admin center, otherwise the OAuth login will not work. Refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri) for more details.
|
|
||||||
|
|
||||||
Redirect URL: `https://<your-domain>/api/oauth/callback/microsoft`
|
Redirect URL: `https://<your-domain>/api/oauth/callback/microsoft`
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
|
|||||||
|
|
||||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik and Casdoor.
|
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik and Casdoor.
|
||||||
|
|
||||||
Redirect URL: `https://<your-domain>/api/oauth/callback/oidc`
|
Redirect URI: `https://<your-domain>/api/oauth/callback/oidc`
|
||||||
|
|
||||||
|
Post Logout Redirect URI: `https://<your-domain>`
|
||||||
|
|
||||||
## Custom your OAuth 2 Provider
|
## Custom your OAuth 2 Provider
|
||||||
|
|
||||||
@@ -67,8 +72,8 @@ const configVariables: ConfigVariables = {
|
|||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Create provider class
|
### 2. Create provider class
|
||||||
@@ -106,23 +111,29 @@ Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts)
|
|||||||
// your provider
|
// your provider
|
||||||
{
|
{
|
||||||
provide: "OAUTH_PROVIDERS",
|
provide: "OAUTH_PROVIDERS",
|
||||||
useFactory(github: GitHubProvider, /* your provider */): Record<string, OAuthProvider<unknown>> {
|
useFactory(
|
||||||
|
github: GitHubProvider /* your provider */
|
||||||
|
): Record<string, OAuthProvider<unknown>> {
|
||||||
return {
|
return {
|
||||||
github,
|
github,
|
||||||
/* your provider */
|
/* your provider */
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
inject: [GitHubProvider, /* your provider */],
|
inject: [GitHubProvider /* your provider */],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OAuthModule {
|
export class OAuthModule {}
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface OAuthSignInDto {
|
export interface OAuthSignInDto {
|
||||||
provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/;
|
provider:
|
||||||
|
| "github"
|
||||||
|
| "google"
|
||||||
|
| "microsoft"
|
||||||
|
| "discord"
|
||||||
|
| "oidc" /* your provider*/;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
providerUsername: string;
|
providerUsername: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -136,10 +147,10 @@ Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx).
|
|||||||
```tsx
|
```tsx
|
||||||
const getOAuthIcon = (provider: string) => {
|
const getOAuthIcon = (provider: string) => {
|
||||||
return {
|
return {
|
||||||
'github': <SiGithub />,
|
github: <SiGithub />,
|
||||||
/* your provider */
|
/* your provider */
|
||||||
}[provider];
|
}[provider];
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Add i18n text
|
### 5. Add i18n text
|
||||||
44
docs/docs/setup/upgrading.md
Normal file
44
docs/docs/setup/upgrading.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: upgrading
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrading
|
||||||
|
|
||||||
|
### 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 install
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
Note that environemnt variables are not picked up when using pm2 restart, if you actually want to change configs, you need to run ````pm2 --update-env restart````
|
||||||
64
docs/docusaurus.config.ts
Normal file
64
docs/docusaurus.config.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type * as Preset from "@docusaurus/preset-classic";
|
||||||
|
import type { Config } from "@docusaurus/types";
|
||||||
|
import { themes as prismThemes } from "prism-react-renderer";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
title: "Pingvin Share",
|
||||||
|
tagline:
|
||||||
|
"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.",
|
||||||
|
favicon: "img/pingvinshare.svg",
|
||||||
|
|
||||||
|
url: "https://stonith404.github.io",
|
||||||
|
baseUrl: "/pingvin-share/",
|
||||||
|
organizationName: "stonith404",
|
||||||
|
projectName: "pingvin-share",
|
||||||
|
|
||||||
|
onBrokenLinks: "warn",
|
||||||
|
onBrokenMarkdownLinks: "warn",
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: ["en"],
|
||||||
|
},
|
||||||
|
|
||||||
|
presets: [
|
||||||
|
[
|
||||||
|
"classic",
|
||||||
|
{
|
||||||
|
docs: {
|
||||||
|
routeBasePath: "/",
|
||||||
|
sidebarPath: "./sidebars.ts",
|
||||||
|
editUrl: "https://github.com/stonith404/pingvin-share/edit/main/docs",
|
||||||
|
},
|
||||||
|
blog: false,
|
||||||
|
} satisfies Preset.Options,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
themeConfig: {
|
||||||
|
image: "img/pingvinshare.svg",
|
||||||
|
colorMode: {
|
||||||
|
respectPrefersColorScheme: true,
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
title: "Pingvin Share",
|
||||||
|
logo: {
|
||||||
|
alt: "Pingvin Share Logo",
|
||||||
|
src: "img/pingvinshare.svg",
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
href: "https://github.com/stonith404/pingvin-share",
|
||||||
|
label: "GitHub",
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
prism: {
|
||||||
|
theme: prismThemes.github,
|
||||||
|
darkTheme: prismThemes.dracula,
|
||||||
|
},
|
||||||
|
} satisfies Preset.ThemeConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
14671
docs/package-lock.json
generated
Normal file
14671
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
docs/package.json
Normal file
47
docs/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "pingvindocs",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"docusaurus": "docusaurus",
|
||||||
|
"start": "docusaurus start",
|
||||||
|
"build": "docusaurus build",
|
||||||
|
"swizzle": "docusaurus swizzle",
|
||||||
|
"deploy": "GIT_USER=stonith404 docusaurus deploy",
|
||||||
|
"clear": "docusaurus clear",
|
||||||
|
"serve": "docusaurus serve",
|
||||||
|
"write-translations": "docusaurus write-translations",
|
||||||
|
"write-heading-ids": "docusaurus write-heading-ids",
|
||||||
|
"typecheck": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@docusaurus/core": "3.5.2",
|
||||||
|
"@docusaurus/preset-classic": "3.5.2",
|
||||||
|
"@mdx-js/react": "^3.0.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prism-react-renderer": "^2.4.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@docusaurus/module-type-aliases": "3.5.2",
|
||||||
|
"@docusaurus/tsconfig": "3.5.2",
|
||||||
|
"@docusaurus/types": "3.5.2",
|
||||||
|
"typescript": "~5.6.2"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.5%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 3 chrome version",
|
||||||
|
"last 3 firefox version",
|
||||||
|
"last 5 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
docs/sidebars.ts
Normal file
72
docs/sidebars.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating a sidebar enables you to:
|
||||||
|
- create an ordered group of docs
|
||||||
|
- render a sidebar for each doc of that group
|
||||||
|
- provide next/previous navigation
|
||||||
|
|
||||||
|
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||||
|
|
||||||
|
Create as many sidebars as you want.
|
||||||
|
*/
|
||||||
|
const sidebars: SidebarsConfig = {
|
||||||
|
docsSidebar: [
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id : "introduction",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "category",
|
||||||
|
label: "Getting Started",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "setup/installation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "setup/configuration",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "setup/integrations",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "setup/oauth2login",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "setup/upgrading",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "category",
|
||||||
|
label: "Helping Out",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "help-out/translate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "doc",
|
||||||
|
id: "help-out/contribute",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
label: "Demo",
|
||||||
|
href: "https://pingvin-share.dev.eliasschneider.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
label: "Discord",
|
||||||
|
href: "https://discord.gg/HutpbfB59Q",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sidebars;
|
||||||
6
docs/src/pages/index.tsx
Normal file
6
docs/src/pages/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <Redirect to='/pingvin-share/introduction' />;
|
||||||
|
}
|
||||||
BIN
docs/static/img/image.png
vendored
Normal file
BIN
docs/static/img/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
1
docs/static/img/pingvinshare.svg
vendored
Normal file
1
docs/static/img/pingvinshare.svg
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>
|
||||||
|
After Width: | Height: | Size: 1018 B |
7
docs/tsconfig.json
Normal file
7
docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||||
|
"extends": "@docusaurus/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
7170
frontend/package-lock.json
generated
7170
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.23.1",
|
"version": "1.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -9,46 +9,46 @@
|
|||||||
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/server": "^11.11.0",
|
"@emotion/server": "^11.11.0",
|
||||||
"@mantine/core": "^6.0.17",
|
"@mantine/core": "^6.0.21",
|
||||||
"@mantine/dropzone": "^6.0.17",
|
"@mantine/dropzone": "^6.0.21",
|
||||||
"@mantine/form": "^6.0.17",
|
"@mantine/form": "^6.0.21",
|
||||||
"@mantine/hooks": "^6.0.17",
|
"@mantine/hooks": "^6.0.21",
|
||||||
"@mantine/modals": "^6.0.17",
|
"@mantine/modals": "^6.0.21",
|
||||||
"@mantine/next": "^6.0.17",
|
"@mantine/next": "^6.0.21",
|
||||||
"@mantine/notifications": "^6.0.17",
|
"@mantine/notifications": "^6.0.21",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.7.7",
|
||||||
"cookies-next": "^2.1.2",
|
"cookies-next": "^4.2.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.14.4",
|
"jose": "^5.9.2",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^4.0.0",
|
||||||
"markdown-to-jsx": "^7.4.1",
|
"markdown-to-jsx": "^7.5.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.30.1",
|
||||||
"next": "^13.4.12",
|
"next": "^14.2.12",
|
||||||
"next-cookies": "^2.0.3",
|
"next-http-proxy-middleware": "^1.2.6",
|
||||||
"next-http-proxy-middleware": "^1.2.5",
|
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"p-limit": "^4.0.0",
|
"p-limit": "^6.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^4.10.1",
|
"react-icons": "^5.3.0",
|
||||||
"react-intl": "^6.4.4",
|
"react-intl": "^6.6.8",
|
||||||
"sharp": "^0.32.4",
|
"sharp": "^0.33.5",
|
||||||
"yup": "^1.2.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/node": "20.4.5",
|
"@types/node": "22.5.5",
|
||||||
"@types/react": "18.2.17",
|
"@types/react": "18.3.7",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.3.0",
|
||||||
"axios": "^1.4.0",
|
"@typescript-eslint/parser": "^8.6.0",
|
||||||
"eslint": "8.46.0",
|
"axios": "^1.7.7",
|
||||||
"eslint-config-next": "^13.4.12",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-prettier": "^8.9.0",
|
"eslint-config-next": "^14.2.12",
|
||||||
"prettier": "^3.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"tar": "^6.1.15",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "^5.1.6"
|
"tar": "^7.4.3",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const CreateEnableTotpModal = ({
|
|||||||
</span>
|
</span>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
<Tooltip label={t("account.modal.totp.clickToCopy")}>
|
<Tooltip label={t("common.button.clickToCopy")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(options.secret);
|
navigator.clipboard.writeText(options.secret);
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ const showShareInformationsModal = (
|
|||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
const link = `${appUrl}/s/${share.id}`;
|
const link = `${appUrl}/s/${share.id}`;
|
||||||
|
|
||||||
let shareSize: number = 0;
|
const formattedShareSize = byteToHumanSizeString(share.size);
|
||||||
for (let file of share.files as FileMetaData[])
|
|
||||||
shareSize += parseInt(file.size);
|
|
||||||
|
|
||||||
const formattedShareSize = byteToHumanSizeString(shareSize);
|
|
||||||
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
|
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
|
||||||
const shareSizeProgress = (shareSize / maxShareSize) * 100;
|
const shareSizeProgress = (share.size / maxShareSize) * 100;
|
||||||
|
|
||||||
const formattedCreatedAt = moment(share.createdAt).format("LLL");
|
const formattedCreatedAt = moment(share.createdAt).format("LLL");
|
||||||
const formattedExpiration =
|
const formattedExpiration =
|
||||||
@@ -42,12 +38,18 @@ const showShareInformationsModal = (
|
|||||||
</b>
|
</b>
|
||||||
{share.id}
|
{share.id}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.name" />:{" "}
|
||||||
|
</b>
|
||||||
|
{share.name || "-"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
<b>
|
<b>
|
||||||
<FormattedMessage id="account.shares.table.description" />:{" "}
|
<FormattedMessage id="account.shares.table.description" />:{" "}
|
||||||
</b>
|
</b>
|
||||||
{share.description || "No description"}
|
{share.description || "-"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
@@ -75,15 +77,15 @@ const showShareInformationsModal = (
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Flex align="center" justify="center">
|
<Flex align="center" justify="center">
|
||||||
{shareSize / maxShareSize < 0.1 && (
|
{share.size / maxShareSize < 0.1 && (
|
||||||
<Text size="xs" style={{ marginRight: "4px" }}>
|
<Text size="xs" style={{ marginRight: "4px" }}>
|
||||||
{formattedShareSize}
|
{formattedShareSize}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Progress
|
<Progress
|
||||||
value={shareSizeProgress}
|
value={shareSizeProgress}
|
||||||
label={shareSize / maxShareSize >= 0.1 ? formattedShareSize : ""}
|
label={share.size / maxShareSize >= 0.1 ? formattedShareSize : ""}
|
||||||
style={{ width: shareSize / maxShareSize < 0.1 ? "70%" : "80%" }}
|
style={{ width: share.size / maxShareSize < 0.1 ? "70%" : "80%" }}
|
||||||
size="xl"
|
size="xl"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb";
|
import {
|
||||||
|
TbAt,
|
||||||
|
TbMail,
|
||||||
|
TbShare,
|
||||||
|
TbSocial,
|
||||||
|
TbSquare,
|
||||||
|
TbBinaryTree,
|
||||||
|
} from "react-icons/tb";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
@@ -20,6 +27,7 @@ const categories = [
|
|||||||
{ name: "Share", icon: <TbShare /> },
|
{ name: "Share", icon: <TbShare /> },
|
||||||
{ name: "SMTP", icon: <TbAt /> },
|
{ name: "SMTP", icon: <TbAt /> },
|
||||||
{ name: "OAuth", icon: <TbSocial /> },
|
{ name: "OAuth", icon: <TbSocial /> },
|
||||||
|
{ name: "LDAP", icon: <TbBinaryTree /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const LogoConfigInput = ({
|
|||||||
value={logo}
|
value={logo}
|
||||||
onChange={(v) => setLogo(v)}
|
onChange={(v) => setLogo(v)}
|
||||||
accept=".png"
|
accept=".png"
|
||||||
|
// @ts-ignore (https://github.com/mantinedev/mantine/issues/5401)
|
||||||
placeholder={t("admin.config.general.logo.placeholder")}
|
placeholder={t("admin.config.general.logo.placeholder")}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
149
frontend/src/components/admin/shares/ManageShareTable.tsx
Normal file
149
frontend/src/components/admin/shares/ManageShareTable.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
MediaQuery,
|
||||||
|
Skeleton,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import moment from "moment";
|
||||||
|
import { TbLink, TbTrash } from "react-icons/tb";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useConfig from "../../../hooks/config.hook";
|
||||||
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
|
import { MyShare } from "../../../types/share.type";
|
||||||
|
import { byteToHumanSizeString } from "../../../utils/fileSize.util";
|
||||||
|
import toast from "../../../utils/toast.util";
|
||||||
|
import showShareLinkModal from "../../account/showShareLinkModal";
|
||||||
|
|
||||||
|
const ManageShareTable = ({
|
||||||
|
shares,
|
||||||
|
deleteShare,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
shares: MyShare[];
|
||||||
|
deleteShare: (share: MyShare) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
const config = useConfig();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="account.shares.table.id" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="account.shares.table.name" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="admin.shares.table.username" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="account.shares.table.visitors" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="account.shares.table.size" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="account.shares.table.expiresAt" />
|
||||||
|
</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading
|
||||||
|
? skeletonRows
|
||||||
|
: shares.map((share) => (
|
||||||
|
<tr key={share.id}>
|
||||||
|
<td>{share.id}</td>
|
||||||
|
<td>{share.name}</td>
|
||||||
|
<td>
|
||||||
|
{share.creator ? (
|
||||||
|
share.creator.username
|
||||||
|
) : (
|
||||||
|
<Text color="dimmed">Anonymous</Text>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{share.views}</td>
|
||||||
|
<td>{byteToHumanSizeString(share.size)}</td>
|
||||||
|
<td>
|
||||||
|
{moment(share.expiration).unix() === 0
|
||||||
|
? "Never"
|
||||||
|
: moment(share.expiration).format("LLL")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Group position="right">
|
||||||
|
<ActionIcon
|
||||||
|
color="victoria"
|
||||||
|
variant="light"
|
||||||
|
size={25}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
clipboard.copy(
|
||||||
|
`${config.get("general.appUrl")}/s/${share.id}`,
|
||||||
|
);
|
||||||
|
toast.success(t("common.notify.copied"));
|
||||||
|
} else {
|
||||||
|
showShareLinkModal(
|
||||||
|
modals,
|
||||||
|
share.id,
|
||||||
|
config.get("general.appUrl"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TbLink />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteShare(share)}
|
||||||
|
>
|
||||||
|
<TbTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const skeletonRows = [...Array(10)].map((v, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
</MediaQuery>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Skeleton key={i} height={20} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
export default ManageShareTable;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
import { ActionIcon, Badge, Box, Group, Skeleton, Table } from "@mantine/core";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||||
import User from "../../../types/user.type";
|
import User from "../../../types/user.type";
|
||||||
@@ -40,21 +40,28 @@ const ManageUserTable = ({
|
|||||||
? skeletonRows
|
? skeletonRows
|
||||||
: users.map((user) => (
|
: users.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td>{user.username}</td>
|
<td>
|
||||||
|
{user.username}{" "}
|
||||||
|
{user.isLdap ? (
|
||||||
|
<Badge style={{ marginLeft: "1em" }}>LDAP</Badge>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
<td>{user.email}</td>
|
<td>{user.email}</td>
|
||||||
<td>{user.isAdmin && <TbCheck />}</td>
|
<td>{user.isAdmin && <TbCheck />}</td>
|
||||||
<td>
|
<td>
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<ActionIcon
|
{user.isLdap ? null : (
|
||||||
variant="light"
|
<ActionIcon
|
||||||
color="primary"
|
variant="light"
|
||||||
size="sm"
|
color="primary"
|
||||||
onClick={() =>
|
size="sm"
|
||||||
showUpdateUserModal(modals, user, getUsers)
|
onClick={() =>
|
||||||
}
|
showUpdateUserModal(modals, user, getUsers)
|
||||||
>
|
}
|
||||||
<TbEdit />
|
>
|
||||||
</ActionIcon>
|
<TbEdit />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
createStyles,
|
createStyles,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -15,7 +16,7 @@ import { useForm, yupResolver } from "@mantine/form";
|
|||||||
import { showNotification } from "@mantine/notifications";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TbInfoCircle } from "react-icons/tb";
|
import { TbInfoCircle } from "react-icons/tb";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
@@ -24,10 +25,23 @@ import useUser from "../../hooks/user.hook";
|
|||||||
import useTranslate from "../../hooks/useTranslate.hook";
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
||||||
import toast from "../../utils/toast.util";
|
|
||||||
import { safeRedirectPath } from "../../utils/router.util";
|
import { safeRedirectPath } from "../../utils/router.util";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
|
signInWith: {
|
||||||
|
fontWeight: 500,
|
||||||
|
"&:before": {
|
||||||
|
content: "''",
|
||||||
|
flex: 1,
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
"&:after": {
|
||||||
|
content: "''",
|
||||||
|
flex: 1,
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
},
|
||||||
or: {
|
or: {
|
||||||
"&:before": {
|
"&:before": {
|
||||||
content: "''",
|
content: "''",
|
||||||
@@ -61,7 +75,9 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
const { refreshUser } = useUser();
|
const { refreshUser } = useUser();
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
|
|
||||||
const [oauth, setOAuth] = React.useState<string[]>([]);
|
const [oauthProviders, setOauthProviders] = useState<string[] | null>(null);
|
||||||
|
const [isRedirectingToOauthProvider, setIsRedirectingToOauthProvider] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||||
@@ -81,7 +97,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
await authService
|
await authService
|
||||||
.signIn(email, password)
|
.signIn(email.trim(), password.trim())
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (response.data["loginToken"]) {
|
if (response.data["loginToken"]) {
|
||||||
// Prompt the user to enter their totp code
|
// Prompt the user to enter their totp code
|
||||||
@@ -105,15 +121,36 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAvailableOAuth = async () => {
|
useEffect(() => {
|
||||||
const oauth = await authService.getAvailableOAuth();
|
authService
|
||||||
setOAuth(oauth.data);
|
.getAvailableOAuth()
|
||||||
};
|
.then((providers) => {
|
||||||
|
setOauthProviders(providers.data);
|
||||||
React.useEffect(() => {
|
if (
|
||||||
getAvailableOAuth().catch(toast.axiosError);
|
providers.data.length === 1 &&
|
||||||
|
config.get("oauth.disablePassword")
|
||||||
|
) {
|
||||||
|
setIsRedirectingToOauthProvider(true);
|
||||||
|
router.push(
|
||||||
|
getOAuthUrl(config.get("general.appUrl"), providers.data[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!oauthProviders) return null;
|
||||||
|
|
||||||
|
if (isRedirectingToOauthProvider)
|
||||||
|
return (
|
||||||
|
<Group align="center" position="center">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text align="center">
|
||||||
|
<FormattedMessage id="common.text.redirecting" />
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title order={2} align="center" weight={900}>
|
<Title order={2} align="center" weight={900}>
|
||||||
@@ -128,49 +165,58 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
<form
|
{config.get("oauth.disablePassword") || (
|
||||||
onSubmit={form.onSubmit((values) => {
|
<form
|
||||||
signIn(values.emailOrUsername, values.password);
|
onSubmit={form.onSubmit((values) => {
|
||||||
})}
|
signIn(values.emailOrUsername, values.password);
|
||||||
>
|
})}
|
||||||
<TextInput
|
>
|
||||||
label={t("signin.input.email-or-username")}
|
<TextInput
|
||||||
placeholder={t("signin.input.email-or-username.placeholder")}
|
label={t("signin.input.email-or-username")}
|
||||||
{...form.getInputProps("emailOrUsername")}
|
placeholder={t("signin.input.email-or-username.placeholder")}
|
||||||
/>
|
{...form.getInputProps("emailOrUsername")}
|
||||||
<PasswordInput
|
/>
|
||||||
label={t("signin.input.password")}
|
<PasswordInput
|
||||||
placeholder={t("signin.input.password.placeholder")}
|
label={t("signin.input.password")}
|
||||||
mt="md"
|
placeholder={t("signin.input.password.placeholder")}
|
||||||
{...form.getInputProps("password")}
|
mt="md"
|
||||||
/>
|
{...form.getInputProps("password")}
|
||||||
{config.get("smtp.enabled") && (
|
/>
|
||||||
<Group position="right" mt="xs">
|
{config.get("smtp.enabled") && (
|
||||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
<Group position="right" mt="xs">
|
||||||
<FormattedMessage id="resetPassword.title" />
|
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||||
</Anchor>
|
<FormattedMessage id="resetPassword.title" />
|
||||||
</Group>
|
</Anchor>
|
||||||
)}
|
</Group>
|
||||||
<Button fullWidth mt="xl" type="submit">
|
)}
|
||||||
<FormattedMessage id="signin.button.submit" />
|
<Button fullWidth mt="xl" type="submit">
|
||||||
</Button>
|
<FormattedMessage id="signin.button.submit" />
|
||||||
</form>
|
</Button>
|
||||||
{oauth.length > 0 && (
|
</form>
|
||||||
<Stack mt="xl">
|
)}
|
||||||
<Group align="center" className={classes.or}>
|
{oauthProviders.length > 0 && (
|
||||||
<Text>{t("signIn.oauth.or")}</Text>
|
<Stack mt={config.get("oauth.disablePassword") ? undefined : "xl"}>
|
||||||
</Group>
|
{config.get("oauth.disablePassword") ? (
|
||||||
|
<Group align="center" className={classes.signInWith}>
|
||||||
|
<Text>{t("signIn.oauth.signInWith")}</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group align="center" className={classes.or}>
|
||||||
|
<Text>{t("signIn.oauth.or")}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
<Group position="center">
|
<Group position="center">
|
||||||
{oauth.map((provider) => (
|
{oauthProviders.map((provider) => (
|
||||||
<Button
|
<Button
|
||||||
key={provider}
|
key={provider}
|
||||||
component="a"
|
component="a"
|
||||||
target="_blank"
|
|
||||||
title={t(`signIn.oauth.${provider}`)}
|
title={t(`signIn.oauth.${provider}`)}
|
||||||
href={getOAuthUrl(config.get("general.appUrl"), provider)}
|
href={getOAuthUrl(config.get("general.appUrl"), provider)}
|
||||||
variant="light"
|
variant="light"
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
{getOAuthIcon(provider)}
|
{getOAuthIcon(provider)}
|
||||||
|
{"\u2002" + t(`signIn.oauth.${provider}`)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const SignUpForm = () => {
|
|||||||
|
|
||||||
const signUp = async (email: string, username: string, password: string) => {
|
const signUp = async (email: string, username: string, password: string) => {
|
||||||
await authService
|
await authService
|
||||||
.signUp(email, username, password)
|
.signUp(email.trim(), username.trim(), password.trim())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const user = await refreshUser();
|
const user = await refreshUser();
|
||||||
if (user?.isAdmin) {
|
if (user?.isAdmin) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const FileList = ({
|
|||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
const [sort, setSort] = useState<TableSort>({
|
const [sort, setSort] = useState<TableSort>({
|
||||||
property: undefined,
|
property: "name",
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { getCookie, setCookie } from "cookies-next";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import * as yup from "yup";
|
||||||
import useTranslate, {
|
import useTranslate, {
|
||||||
translateOutsideContext,
|
translateOutsideContext,
|
||||||
} from "../../../hooks/useTranslate.hook";
|
} from "../../../hooks/useTranslate.hook";
|
||||||
@@ -61,10 +63,26 @@ const Body = ({
|
|||||||
sendEmailNotification: false,
|
sendEmailNotification: false,
|
||||||
expiration_num: 1,
|
expiration_num: 1,
|
||||||
expiration_unit: "-days",
|
expiration_unit: "-days",
|
||||||
|
simplified: !!(getCookie("reverse-share.simplified") ?? false),
|
||||||
|
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
|
||||||
},
|
},
|
||||||
|
validate: yupResolver(
|
||||||
|
yup.object().shape({
|
||||||
|
maxUseCount: yup
|
||||||
|
.number()
|
||||||
|
.typeError(t("common.error.invalid-number"))
|
||||||
|
.min(1, t("common.error.number-too-small", { min: 1 }))
|
||||||
|
.max(1000, t("common.error.number-too-large", { max: 1000 }))
|
||||||
|
.required(t("common.error.field-required")),
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = form.onSubmit(async (values) => {
|
const onSubmit = form.onSubmit(async (values) => {
|
||||||
|
// remember simplified and publicAccess in cookies
|
||||||
|
setCookie("reverse-share.simplified", values.simplified);
|
||||||
|
setCookie("reverse-share.public-access", values.publicAccess);
|
||||||
|
|
||||||
const expirationDate = moment().add(
|
const expirationDate = moment().add(
|
||||||
form.values.expiration_num,
|
form.values.expiration_num,
|
||||||
form.values.expiration_unit.replace(
|
form.values.expiration_unit.replace(
|
||||||
@@ -91,6 +109,8 @@ const Body = ({
|
|||||||
values.maxShareSize,
|
values.maxShareSize,
|
||||||
values.maxUseCount,
|
values.maxUseCount,
|
||||||
values.sendEmailNotification,
|
values.sendEmailNotification,
|
||||||
|
values.simplified,
|
||||||
|
values.publicAccess,
|
||||||
)
|
)
|
||||||
.then(({ link }) => {
|
.then(({ link }) => {
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
@@ -210,7 +230,28 @@ const Body = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label={t("account.reverseShares.modal.simplified")}
|
||||||
|
description={t(
|
||||||
|
"account.reverseShares.modal.simplified.description",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("simplified", {
|
||||||
|
type: "checkbox",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
mt="xs"
|
||||||
|
labelPosition="left"
|
||||||
|
label={t("account.reverseShares.modal.public-access")}
|
||||||
|
description={t(
|
||||||
|
"account.reverseShares.modal.public-access.description",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("publicAccess", {
|
||||||
|
type: "checkbox",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<Button mt="md" type="submit">
|
<Button mt="md" type="submit">
|
||||||
<FormattedMessage id="common.button.create" />
|
<FormattedMessage id="common.button.create" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -53,10 +53,18 @@ function CopyTextField(props: { link: string }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{window.isSecureContext && (
|
{window.isSecureContext && (
|
||||||
<ActionIcon onClick={copyLink}>
|
<Tooltip
|
||||||
{checkState ? <TbCheck /> : <TbCopy />}
|
label={t("common.button.clickToCopy")}
|
||||||
</ActionIcon>
|
position="top"
|
||||||
|
offset={-2}
|
||||||
|
openDelay={200}
|
||||||
|
>
|
||||||
|
<ActionIcon onClick={copyLink}>
|
||||||
|
{checkState ? <TbCheck /> : <TbCopy />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ const Dropzone = ({
|
|||||||
title,
|
title,
|
||||||
isUploading,
|
isUploading,
|
||||||
maxShareSize,
|
maxShareSize,
|
||||||
showCreateUploadModalCallback,
|
onFilesChanged,
|
||||||
}: {
|
}: {
|
||||||
title?: string;
|
title?: string;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
maxShareSize: number;
|
maxShareSize: number;
|
||||||
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
onFilesChanged: (files: FileUpload[]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ const Dropzone = ({
|
|||||||
newFile.uploadingProgress = 0;
|
newFile.uploadingProgress = 0;
|
||||||
return newFile;
|
return newFile;
|
||||||
});
|
});
|
||||||
showCreateUploadModalCallback(files);
|
onFilesChanged(files);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classes.dropzone}
|
className={classes.dropzone}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const EditableUpload = ({
|
|||||||
<Dropzone
|
<Dropzone
|
||||||
title={t("share.edit.append-upload")}
|
title={t("share.edit.append-upload")}
|
||||||
maxShareSize={maxShareSize}
|
maxShareSize={maxShareSize}
|
||||||
showCreateUploadModalCallback={appendFiles}
|
onFilesChanged={appendFiles}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
/>
|
/>
|
||||||
{existingAndUploadedFiles.length > 0 && (
|
{existingAndUploadedFiles.length > 0 && (
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import { FormattedMessage } from "react-intl";
|
|||||||
import useTranslate, {
|
import useTranslate, {
|
||||||
translateOutsideContext,
|
translateOutsideContext,
|
||||||
} from "../../../hooks/useTranslate.hook";
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import { Share } from "../../../types/share.type";
|
import { CompletedShare } from "../../../types/share.type";
|
||||||
import CopyTextField from "../CopyTextField";
|
import CopyTextField from "../CopyTextField";
|
||||||
|
|
||||||
const showCompletedUploadModal = (
|
const showCompletedUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
share: Share,
|
share: CompletedShare,
|
||||||
appUrl: string,
|
appUrl: string,
|
||||||
) => {
|
) => {
|
||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
@@ -25,7 +25,7 @@ const showCompletedUploadModal = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -35,6 +35,19 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<CopyTextField link={link} />
|
<CopyTextField link={link} />
|
||||||
|
{share.notifyReverseShareCreator === true && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
sx={(theme) => ({
|
||||||
|
color:
|
||||||
|
theme.colorScheme === "dark"
|
||||||
|
? theme.colors.gray[3]
|
||||||
|
: theme.colors.dark[4],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t("upload.modal.completed.notified-reverse-share-creator")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import shareService from "../../../services/share.service";
|
|||||||
import { FileUpload } from "../../../types/File.type";
|
import { FileUpload } from "../../../types/File.type";
|
||||||
import { CreateShare } from "../../../types/share.type";
|
import { CreateShare } from "../../../types/share.type";
|
||||||
import { getExpirationPreview } from "../../../utils/date.util";
|
import { getExpirationPreview } from "../../../utils/date.util";
|
||||||
|
import React from "react";
|
||||||
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showCreateUploadModal = (
|
const showCreateUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
@@ -40,12 +42,26 @@ const showCreateUploadModal = (
|
|||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
maxExpirationInHours: number;
|
maxExpirationInHours: number;
|
||||||
|
simplified: boolean;
|
||||||
},
|
},
|
||||||
files: FileUpload[],
|
files: FileUpload[],
|
||||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
||||||
) => {
|
) => {
|
||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
|
|
||||||
|
if (options.simplified) {
|
||||||
|
return modals.openModal({
|
||||||
|
title: t("upload.modal.title"),
|
||||||
|
children: (
|
||||||
|
<SimplifiedCreateUploadModalModal
|
||||||
|
options={options}
|
||||||
|
files={files}
|
||||||
|
uploadCallback={uploadCallback}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: t("upload.modal.title"),
|
title: t("upload.modal.title"),
|
||||||
children: (
|
children: (
|
||||||
@@ -58,6 +74,23 @@ const showCreateUploadModal = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateLink = () =>
|
||||||
|
Buffer.from(Math.random().toString(), "utf8")
|
||||||
|
.toString("base64")
|
||||||
|
.substring(10, 17);
|
||||||
|
|
||||||
|
const generateAvailableLink = async (times = 10): Promise<string> => {
|
||||||
|
if (times <= 0) {
|
||||||
|
throw new Error("Could not generate available link");
|
||||||
|
}
|
||||||
|
const _link = generateLink();
|
||||||
|
if (!(await shareService.isShareIdAvailable(_link))) {
|
||||||
|
return await generateAvailableLink(times - 1);
|
||||||
|
} else {
|
||||||
|
return _link;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const CreateUploadModalBody = ({
|
const CreateUploadModalBody = ({
|
||||||
uploadCallback,
|
uploadCallback,
|
||||||
files,
|
files,
|
||||||
@@ -77,9 +110,7 @@ const CreateUploadModalBody = ({
|
|||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
|
const generatedLink = generateLink();
|
||||||
.toString("base64")
|
|
||||||
.substr(10, 7);
|
|
||||||
|
|
||||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
|
|
||||||
@@ -92,11 +123,16 @@ const CreateUploadModalBody = ({
|
|||||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||||
message: t("upload.modal.link.error.invalid"),
|
message: t("upload.modal.link.error.invalid"),
|
||||||
}),
|
}),
|
||||||
|
name: yup
|
||||||
|
.string()
|
||||||
|
.transform((value) => value || undefined)
|
||||||
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
|
.max(30, t("common.error.too-long", { length: 30 })),
|
||||||
password: yup
|
password: yup
|
||||||
.string()
|
.string()
|
||||||
.transform((value) => value || undefined)
|
.transform((value) => value || undefined)
|
||||||
.min(3)
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
.max(30),
|
.max(30, t("common.error.too-long", { length: 30 })),
|
||||||
maxViews: yup
|
maxViews: yup
|
||||||
.number()
|
.number()
|
||||||
.transform((value) => value || undefined)
|
.transform((value) => value || undefined)
|
||||||
@@ -105,6 +141,7 @@ const CreateUploadModalBody = ({
|
|||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
name: undefined,
|
||||||
link: generatedLink,
|
link: generatedLink,
|
||||||
recipients: [] as string[],
|
recipients: [] as string[],
|
||||||
password: undefined,
|
password: undefined,
|
||||||
@@ -154,6 +191,7 @@ const CreateUploadModalBody = ({
|
|||||||
uploadCallback(
|
uploadCallback(
|
||||||
{
|
{
|
||||||
id: values.link,
|
id: values.link,
|
||||||
|
name: values.name,
|
||||||
expiration: expirationString,
|
expiration: expirationString,
|
||||||
recipients: values.recipients,
|
recipients: values.recipients,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
@@ -194,14 +232,7 @@ const CreateUploadModalBody = ({
|
|||||||
<Button
|
<Button
|
||||||
style={{ flex: "0 0 auto" }}
|
style={{ flex: "0 0 auto" }}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => form.setFieldValue("link", generateLink())}
|
||||||
form.setFieldValue(
|
|
||||||
"link",
|
|
||||||
Buffer.from(Math.random().toString(), "utf8")
|
|
||||||
.toString("base64")
|
|
||||||
.substr(10, 7),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FormattedMessage id="common.button.generate" />
|
<FormattedMessage id="common.button.generate" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -235,7 +266,6 @@ const CreateUploadModalBody = ({
|
|||||||
disabled={form.values.never_expires}
|
disabled={form.values.never_expires}
|
||||||
{...form.getInputProps("expiration_unit")}
|
{...form.getInputProps("expiration_unit")}
|
||||||
data={[
|
data={[
|
||||||
// Set the label to singular if the number is 1, else plural
|
|
||||||
{
|
{
|
||||||
value: "-minutes",
|
value: "-minutes",
|
||||||
label:
|
label:
|
||||||
@@ -308,14 +338,21 @@ const CreateUploadModalBody = ({
|
|||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
<FormattedMessage id="upload.modal.accordion.description.title" />
|
<FormattedMessage id="upload.modal.accordion.name-and-description.title" />
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.name-and-description.name.placeholder",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"upload.modal.accordion.description.placeholder",
|
"upload.modal.accordion.name-and-description.description.placeholder",
|
||||||
)}
|
)}
|
||||||
{...form.getInputProps("description")}
|
{...form.getInputProps("description")}
|
||||||
/>
|
/>
|
||||||
@@ -333,7 +370,8 @@ const CreateUploadModalBody = ({
|
|||||||
placeholder={t("upload.modal.accordion.email.placeholder")}
|
placeholder={t("upload.modal.accordion.email.placeholder")}
|
||||||
searchable
|
searchable
|
||||||
creatable
|
creatable
|
||||||
autoComplete="email-recipients"
|
id="recipient-emails"
|
||||||
|
inputMode="email"
|
||||||
getCreateLabel={(query) => `+ ${query}`}
|
getCreateLabel={(query) => `+ ${query}`}
|
||||||
onCreate={(query) => {
|
onCreate={(query) => {
|
||||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||||
@@ -351,6 +389,25 @@ const CreateUploadModalBody = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
{...form.getInputProps("recipients")}
|
{...form.getInputProps("recipients")}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
// Add email on comma or semicolon
|
||||||
|
if (e.key === "," || e.key === ";") {
|
||||||
|
e.preventDefault();
|
||||||
|
const inputValue = (
|
||||||
|
e.target as HTMLInputElement
|
||||||
|
).value.trim();
|
||||||
|
if (inputValue.match(/^\S+@\S+\.\S+$/)) {
|
||||||
|
form.setFieldValue("recipients", [
|
||||||
|
...form.values.recipients,
|
||||||
|
inputValue,
|
||||||
|
]);
|
||||||
|
(e.target as HTMLInputElement).value = "";
|
||||||
|
}
|
||||||
|
} else if (e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLInputElement).value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
@@ -368,7 +425,7 @@ const CreateUploadModalBody = ({
|
|||||||
"upload.modal.accordion.security.password.placeholder",
|
"upload.modal.accordion.security.password.placeholder",
|
||||||
)}
|
)}
|
||||||
label={t("upload.modal.accordion.security.password.label")}
|
label={t("upload.modal.accordion.security.password.label")}
|
||||||
autoComplete="off"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -394,4 +451,108 @@ const CreateUploadModalBody = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SimplifiedCreateUploadModalModal = ({
|
||||||
|
uploadCallback,
|
||||||
|
files,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
files: FileUpload[];
|
||||||
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
|
||||||
|
options: {
|
||||||
|
isUserSignedIn: boolean;
|
||||||
|
isReverseShare: boolean;
|
||||||
|
appUrl: string;
|
||||||
|
allowUnauthenticatedShares: boolean;
|
||||||
|
enableEmailRecepients: boolean;
|
||||||
|
maxExpirationInHours: number;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
|
|
||||||
|
const validationSchema = yup.object().shape({
|
||||||
|
name: yup
|
||||||
|
.string()
|
||||||
|
.transform((value) => value || undefined)
|
||||||
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
|
.max(30, t("common.error.too-long", { length: 30 })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
},
|
||||||
|
validate: yupResolver(validationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = form.onSubmit(async (values) => {
|
||||||
|
const link = await generateAvailableLink().catch(() => {
|
||||||
|
toast.error(t("upload.modal.link.error.taken"));
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadCallback(
|
||||||
|
{
|
||||||
|
id: link,
|
||||||
|
name: values.name,
|
||||||
|
expiration: "never",
|
||||||
|
recipients: [],
|
||||||
|
description: values.description,
|
||||||
|
security: {
|
||||||
|
password: undefined,
|
||||||
|
maxViews: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
modals.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||||
|
<Alert
|
||||||
|
withCloseButton
|
||||||
|
onClose={() => setShowNotSignedInAlert(false)}
|
||||||
|
icon={<TbAlertCircle size={16} />}
|
||||||
|
title={t("upload.modal.not-signed-in")}
|
||||||
|
color="yellow"
|
||||||
|
>
|
||||||
|
<FormattedMessage id="upload.modal.not-signed-in-description" />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<Stack align="stretch">
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.name-and-description.name.placeholder",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
variant="filled"
|
||||||
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.name-and-description.description.placeholder",
|
||||||
|
)}
|
||||||
|
{...form.getInputProps("description")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button type="submit" data-autofocus>
|
||||||
|
<FormattedMessage id="common.button.share" />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default showCreateUploadModal;
|
export default showCreateUploadModal;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import arabic from "./translations/ar-EG";
|
||||||
import danish from "./translations/da-DK";
|
import danish from "./translations/da-DK";
|
||||||
import german from "./translations/de-DE";
|
import german from "./translations/de-DE";
|
||||||
import greek from "./translations/el-GR";
|
import greek from "./translations/el-GR";
|
||||||
@@ -5,19 +6,25 @@ import english from "./translations/en-US";
|
|||||||
import spanish from "./translations/es-ES";
|
import spanish from "./translations/es-ES";
|
||||||
import finnish from "./translations/fi-FI";
|
import finnish from "./translations/fi-FI";
|
||||||
import french from "./translations/fr-FR";
|
import french from "./translations/fr-FR";
|
||||||
|
import hungarian from "./translations/hu-HU";
|
||||||
import italian from "./translations/it-IT";
|
import italian from "./translations/it-IT";
|
||||||
import japanese from "./translations/ja-JP";
|
import japanese from "./translations/ja-JP";
|
||||||
|
import korean from "./translations/ko-KR";
|
||||||
import dutch from "./translations/nl-BE";
|
import dutch from "./translations/nl-BE";
|
||||||
import polish from "./translations/pl-PL";
|
import polish from "./translations/pl-PL";
|
||||||
import portuguese from "./translations/pt-BR";
|
import portuguese from "./translations/pt-BR";
|
||||||
import russian from "./translations/ru-RU";
|
import russian from "./translations/ru-RU";
|
||||||
import ukrainian from "./translations/uk-UA";
|
|
||||||
import slovenian from "./translations/sl-SI";
|
import slovenian from "./translations/sl-SI";
|
||||||
import serbian from "./translations/sr-SP";
|
import serbian from "./translations/sr-SP";
|
||||||
import swedish from "./translations/sv-SE";
|
import swedish from "./translations/sv-SE";
|
||||||
import thai from "./translations/th-TH";
|
import thai from "./translations/th-TH";
|
||||||
|
import ukrainian from "./translations/uk-UA";
|
||||||
import chineseSimplified from "./translations/zh-CN";
|
import chineseSimplified from "./translations/zh-CN";
|
||||||
import chineseTraditional from "./translations/zh-TW";
|
import chineseTraditional from "./translations/zh-TW";
|
||||||
|
import turkish from "./translations/tr-TR";
|
||||||
|
import czech from "./translations/cs-CZ";
|
||||||
|
import viatnamese from "./translations/vi-VN";
|
||||||
|
import bulgarian from "./translations/bg-BG";
|
||||||
|
|
||||||
export const LOCALES = {
|
export const LOCALES = {
|
||||||
ENGLISH: {
|
ENGLISH: {
|
||||||
@@ -123,11 +130,36 @@ export const LOCALES = {
|
|||||||
ARABIC: {
|
ARABIC: {
|
||||||
name: "العربية",
|
name: "العربية",
|
||||||
code: "ar-EG",
|
code: "ar-EG",
|
||||||
messages: {},
|
messages: arabic,
|
||||||
},
|
},
|
||||||
HUNGARIAN: {
|
HUNGARIAN: {
|
||||||
name: "Hungarian",
|
name: "Hungarian",
|
||||||
code: "hu-HU",
|
code: "hu-HU",
|
||||||
messages: {},
|
messages: hungarian,
|
||||||
|
},
|
||||||
|
KOREAN: {
|
||||||
|
name: "한국어",
|
||||||
|
code: "ko-KR",
|
||||||
|
messages: korean,
|
||||||
|
},
|
||||||
|
TURKISH: {
|
||||||
|
name: "Türkçe",
|
||||||
|
code: "tr-TR",
|
||||||
|
messages: turkish,
|
||||||
|
},
|
||||||
|
CZECH: {
|
||||||
|
name: "Čeština",
|
||||||
|
code: "cs-CZ",
|
||||||
|
messages: czech,
|
||||||
|
},
|
||||||
|
VIATNAMESE: {
|
||||||
|
name: "Tiếng Việt",
|
||||||
|
code: "vi-VN",
|
||||||
|
messages: viatnamese,
|
||||||
|
},
|
||||||
|
BULGARIAN: {
|
||||||
|
name: "Български",
|
||||||
|
code: "bg-BG",
|
||||||
|
messages: bulgarian,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
export default {
|
export default {
|
||||||
// Navbar
|
// Navbar
|
||||||
"navbar.upload": "Upload",
|
"navbar.upload": "رفع",
|
||||||
"navbar.signin": "Sign in",
|
"navbar.signin": "تسجيل الدخول",
|
||||||
"navbar.home": "Home",
|
"navbar.home": "الصفحة الرئيسية",
|
||||||
"navbar.signup": "Sign Up",
|
"navbar.signup": "إنشاء حساب",
|
||||||
"navbar.links.shares": "My shares",
|
"navbar.links.shares": "مشاركاتي",
|
||||||
"navbar.links.reverse": "Reverse shares",
|
"navbar.links.reverse": "مشاركاتي العكسية",
|
||||||
"navbar.avatar.account": "My account",
|
"navbar.avatar.account": "حسابي",
|
||||||
"navbar.avatar.admin": "Administration",
|
"navbar.avatar.admin": "الإدارة",
|
||||||
"navbar.avatar.signout": "Sign out",
|
"navbar.avatar.signout": "تسجيل الخروج",
|
||||||
// END navbar
|
// END navbar
|
||||||
// /
|
// /
|
||||||
"home.title": "A <h>self-hosted</h> file sharing platform.",
|
"home.title": "منصة لمشاركة الملفات <h>باستضافة ذاتية</h>.",
|
||||||
"home.description": "Do you really want to give your personal files in the hand of third parties like WeTransfer?",
|
"home.description": "أحقًا تريد تسليم ملفاتك الشخصية لطرف ثالث مثل WeTransfer؟",
|
||||||
"home.bullet.a.name": "Self-Hosted",
|
"home.bullet.a.name": "استضافة ذاتية",
|
||||||
"home.bullet.a.description": "Host Pingvin Share on your own machine.",
|
"home.bullet.a.description": "قم باستضافة Pingvin Share على جهازك.",
|
||||||
"home.bullet.b.name": "Privacy",
|
"home.bullet.b.name": "الخصوصية",
|
||||||
"home.bullet.b.description": "Your files are your files and should never get into the hands of third parties.",
|
"home.bullet.b.description": "ملفاتك تخصّك وحدك فقط، ولا ينبغي أبدًا أن تقع بأيدي طرفٍ ثالث.",
|
||||||
"home.bullet.c.name": "No annoying file size limit",
|
"home.bullet.c.name": "ليس هناك أية قيود على حجم الملفات",
|
||||||
"home.bullet.c.description": "Upload as big files as you want. Only your hard drive will be your limit.",
|
"home.bullet.c.description": "ارفع أي ملف تريده مهما كان حجمه كبيرًا. إن مساحة قرصك الصلب هي المحدد الوحيد هنا.",
|
||||||
"home.button.start": "Get started",
|
"home.button.start": "ابدأ",
|
||||||
"home.button.source": "Source code",
|
"home.button.source": "النص البرمجي المصدري",
|
||||||
// END /
|
// END /
|
||||||
// /auth/signin
|
// /auth/signin
|
||||||
"signin.title": "Welcome back",
|
"signin.title": "أهلًا بعودتك",
|
||||||
"signin.description": "You don't have an account yet?",
|
"signin.description": "ليس لديك حساب؟",
|
||||||
"signin.button.signup": "Sign up",
|
"signin.button.signup": "إنشاء حساب",
|
||||||
"signin.input.email-or-username": "Email or username",
|
"signin.input.email-or-username": "البريد أو اسم المستخدم",
|
||||||
"signin.input.email-or-username.placeholder": "Your email or username",
|
"signin.input.email-or-username.placeholder": "بريدك أو اسم المستخدم",
|
||||||
"signin.input.password": "Password",
|
"signin.input.password": "كلمة السر",
|
||||||
"signin.input.password.placeholder": "Your password",
|
"signin.input.password.placeholder": "كلمة السر",
|
||||||
"signin.button.submit": "Sign in",
|
"signin.button.submit": "تسجيل الدخول",
|
||||||
"signIn.notify.totp-required.title": "Two-factor authentication required",
|
"signIn.notify.totp-required.title": "إن المصادقة الثنائية ضرورية",
|
||||||
"signIn.notify.totp-required.description": "Please enter your two-factor authentication code",
|
"signIn.notify.totp-required.description": "فضلًا أدخل رمز المصادقة الثنائية",
|
||||||
"signIn.oauth.or": "OR",
|
"signIn.oauth.or": "أو",
|
||||||
|
"signIn.oauth.signInWith": "تسجيل الدخول بواسطة تطبيق",
|
||||||
"signIn.oauth.github": "GitHub",
|
"signIn.oauth.github": "GitHub",
|
||||||
"signIn.oauth.google": "Google",
|
"signIn.oauth.google": "Google",
|
||||||
"signIn.oauth.microsoft": "Microsoft",
|
"signIn.oauth.microsoft": "Microsoft",
|
||||||
@@ -41,381 +42,439 @@ export default {
|
|||||||
"signIn.oauth.oidc": "OpenID",
|
"signIn.oauth.oidc": "OpenID",
|
||||||
// END /auth/signin
|
// END /auth/signin
|
||||||
// /auth/signup
|
// /auth/signup
|
||||||
"signup.title": "Create an account",
|
"signup.title": "أنشئ حسابًا",
|
||||||
"signup.description": "Already have an account?",
|
"signup.description": "لديك حساب بالفعل؟",
|
||||||
"signup.button.signin": "Sign in",
|
"signup.button.signin": "تسجيل الدخول",
|
||||||
"signup.input.username": "Username",
|
"signup.input.username": "اسم المستخدم",
|
||||||
"signup.input.username.placeholder": "Your username",
|
"signup.input.username.placeholder": "اسم المستخدم",
|
||||||
"signup.input.email": "Email",
|
"signup.input.email": "البريد",
|
||||||
"signup.input.email.placeholder": "Your email",
|
"signup.input.email.placeholder": "بريدك",
|
||||||
"signup.button.submit": "Let's get started",
|
"signup.button.submit": "لنبدأ",
|
||||||
// END /auth/signup
|
// END /auth/signup
|
||||||
// /auth/totp
|
// /auth/totp
|
||||||
"totp.title": "TOTP Authentication",
|
"totp.title": "كلمة المرور لمرة واحدة المؤقتة TOTP",
|
||||||
"totp.button.signIn": "Sign in",
|
"totp.button.signIn": "تسجيل الدخول",
|
||||||
// END /auth/totp
|
// END /auth/totp
|
||||||
// /auth/reset-password
|
// /auth/reset-password
|
||||||
"resetPassword.title": "Forgot your password?",
|
"resetPassword.title": "نسيت كلمة سرّك؟",
|
||||||
"resetPassword.description": "Enter your email to reset your password.",
|
"resetPassword.description": "اكتب بريدك لتعيد تعيين كلمة السر.",
|
||||||
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the email exists.",
|
"resetPassword.notify.success": "إذا كان هذا البريد مسجلًا لدينا فستصله الآن رسالة فيها رابط لإعادة تعيين كلمة السرّ.",
|
||||||
"resetPassword.button.back": "Back to sign in page",
|
"resetPassword.button.back": "العودة لصفحة تسجيل الدخول",
|
||||||
"resetPassword.text.resetPassword": "Reset password",
|
"resetPassword.text.resetPassword": "إعادة تعيين كلمة السر",
|
||||||
"resetPassword.text.enterNewPassword": "Enter your new password",
|
"resetPassword.text.enterNewPassword": "أدخل كلمة السر الجديدة",
|
||||||
"resetPassword.input.password": "New password",
|
"resetPassword.input.password": "كلمة السر الجديدة",
|
||||||
"resetPassword.notify.passwordReset": "Your password has been reset successfully.",
|
"resetPassword.notify.passwordReset": "أعدتَ تعيين كلمة السر بنجاح.",
|
||||||
// /account
|
// /account
|
||||||
"account.title": "My account",
|
"account.title": "حسابي",
|
||||||
"account.card.info.title": "Account info",
|
"account.card.info.title": "معلومات الحساب",
|
||||||
"account.card.info.username": "Username",
|
"account.card.info.username": "اسم المستخدم",
|
||||||
"account.card.info.email": "Email",
|
"account.card.info.email": "البريد",
|
||||||
"account.notify.info.success": "Account updated successfully",
|
"account.notify.info.success": "تم تحديث الحساب بنجاح",
|
||||||
"account.card.password.title": "Password",
|
"account.card.password.title": "كلمة السر",
|
||||||
"account.card.password.old": "Old password",
|
"account.card.password.old": "كلمة السر القديمة",
|
||||||
"account.card.password.new": "New password",
|
"account.card.password.new": "كلمة السر الجديدة",
|
||||||
"account.card.password.noPasswordSet": "You don't have a password set. If you want to sign in with email and password you need to set a password.",
|
"account.card.password.noPasswordSet": "ليس لحسابك كلمة سر. إذا أردت تسجيل الدخول باستخدام البريد وكلمة سر، فعليك أن تُعيِّن كلمة سر.",
|
||||||
"account.notify.password.success": "Password changed successfully",
|
"account.notify.password.success": "غيرت كلمة السر بنجاح",
|
||||||
"account.card.oauth.title": "Social login",
|
"account.card.oauth.title": "الدخول بحساب تواصل اجتماعي",
|
||||||
"account.card.oauth.github": "GitHub",
|
"account.card.oauth.github": "GitHub",
|
||||||
"account.card.oauth.google": "Google",
|
"account.card.oauth.google": "Google",
|
||||||
"account.card.oauth.microsoft": "Microsoft",
|
"account.card.oauth.microsoft": "Microsoft",
|
||||||
"account.card.oauth.discord": "Discord",
|
"account.card.oauth.discord": "Discord",
|
||||||
"account.card.oauth.oidc": "OpenID",
|
"account.card.oauth.oidc": "OpenID",
|
||||||
"account.card.oauth.link": "Link",
|
"account.card.oauth.link": "ربط",
|
||||||
"account.card.oauth.unlink": "Unlink",
|
"account.card.oauth.unlink": "فك الربط",
|
||||||
"account.card.oauth.unlinked": "Unlinked",
|
"account.card.oauth.unlinked": "تم فك الربط",
|
||||||
"account.modal.unlink.title": "Unlink account",
|
"account.modal.unlink.title": "فك ربط الحساب",
|
||||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your username and password.",
|
"account.modal.unlink.description": "قد يؤدي إلغاء ربط حساباتك الاجتماعية إلى فقدان وصولك لحسابك إذا كنت لا تتذكر اسم المستخدم وكلمة السر الخاصة بك.",
|
||||||
"account.notify.oauth.unlinked.success": "Unlinked successfully",
|
"account.notify.oauth.unlinked.success": "تم فك الربط بنجاح",
|
||||||
"account.card.security.title": "Security",
|
"account.card.security.title": "الأمان",
|
||||||
"account.card.security.totp.enable.description": "Enter your current password to start enabling TOTP",
|
"account.card.security.totp.enable.description": "اكتب كلمة سرّك لبدء تمكين TOTP",
|
||||||
"account.card.security.totp.disable.description": "Enter your current password to disable TOTP",
|
"account.card.security.totp.disable.description": "اكتب كلمة سرّك لتعطيل TOTP",
|
||||||
"account.card.security.totp.button.start": "Start",
|
"account.card.security.totp.button.start": "ابدأ",
|
||||||
"account.modal.totp.title": "Enable TOTP",
|
"account.modal.totp.title": "تمكين TOTP",
|
||||||
"account.modal.totp.step1": "Step 1: Add your authenticator",
|
"account.modal.totp.step1": "الخطوة 1: أضف تطبيق المصادقة",
|
||||||
"account.modal.totp.step2": "Step 2: Validate your code",
|
"account.modal.totp.step2": "الخطوة 2: تحقّق من صحة رمزك",
|
||||||
"account.modal.totp.enterManually": "Enter manually",
|
"account.modal.totp.enterManually": "أدخل يدوياً",
|
||||||
"account.modal.totp.code": "Code",
|
"account.modal.totp.code": "الرمز",
|
||||||
"account.modal.totp.clickToCopy": "Click to copy",
|
"common.button.clickToCopy": "انقر للنسخ",
|
||||||
"account.modal.totp.verify": "Verify",
|
"account.modal.totp.verify": "تحقق",
|
||||||
"account.notify.totp.disable": "TOTP disabled successfully",
|
"account.notify.totp.disable": "تم تعطيل TOTP بنجاح",
|
||||||
"account.notify.totp.enable": "TOTP enabled successfully",
|
"account.notify.totp.enable": "تم تمكين TOTP بنجاح",
|
||||||
"account.card.language.title": "Language",
|
"account.card.language.title": "اللغة",
|
||||||
"account.card.language.description": "The project is translated by the community. Some languages might be incomplete.",
|
"account.card.language.description": "يقوم المجتمع بترجمة هذا المشروع. ربما بعض اللغات لم تكتمل ترجمتها بعد.",
|
||||||
"account.card.color.title": "Color scheme",
|
"account.card.color.title": "نظام الألوان",
|
||||||
// ThemeSwitcher.tsx
|
// ThemeSwitcher.tsx
|
||||||
"account.theme.dark": "Dark",
|
"account.theme.dark": "داكن",
|
||||||
"account.theme.light": "Light",
|
"account.theme.light": "فاتح",
|
||||||
"account.theme.system": "System",
|
"account.theme.system": "حسب النظام",
|
||||||
"account.button.delete": "Delete Account",
|
"account.button.delete": "حذف الحساب",
|
||||||
"account.modal.delete.title": "Delete Account",
|
"account.modal.delete.title": "حذف الحساب",
|
||||||
"account.modal.delete.description": "Do you really want to delete your account including all your active shares?",
|
"account.modal.delete.description": "هل تريد حقاً حذف حسابك بما في ذلك جميع مشاركاتك النشطة؟",
|
||||||
// END /account
|
// END /account
|
||||||
// /account/shares
|
// /account/shares
|
||||||
"account.shares.title": "My shares",
|
"account.shares.title": "مشاركاتي",
|
||||||
"account.shares.title.empty": "It's empty here 👀",
|
"account.shares.title.empty": "المكان خالٍ هنا 👀",
|
||||||
"account.shares.description.empty": "You don't have any shares.",
|
"account.shares.description.empty": "ليس لديك أي مشاركات.",
|
||||||
"account.shares.button.create": "Create one",
|
"account.shares.button.create": "أنشئ واحدًا",
|
||||||
"account.shares.info.title": "Share informations",
|
"account.shares.info.title": "معلومات المشاركة",
|
||||||
"account.shares.table.id": "ID",
|
"account.shares.table.id": "الرقم التعريفي",
|
||||||
"account.shares.table.name": "Name",
|
"account.shares.table.name": "الاسم",
|
||||||
"account.shares.table.description": "Description",
|
"account.shares.table.description": "الوصف",
|
||||||
"account.shares.table.visitors": "Visitors",
|
"account.shares.table.visitors": "الزوار",
|
||||||
"account.shares.table.expiresAt": "Expires at",
|
"account.shares.table.expiresAt": "تاريخ انتهاء الصلاحية",
|
||||||
"account.shares.table.createdAt": "Created at",
|
"account.shares.table.createdAt": "تاريخ الإنشاء",
|
||||||
"account.shares.table.size": "Size",
|
"account.shares.table.size": "الحجم",
|
||||||
"account.shares.modal.share-informations": "Share informations",
|
"account.shares.modal.share-informations": "معلومات المشاركة",
|
||||||
"account.shares.modal.share-link": "Share link",
|
"account.shares.modal.share-link": "رابط المشاركة",
|
||||||
"account.shares.modal.delete.title": "Delete share {share}",
|
"account.shares.modal.delete.title": "حذف المشاركة {share}",
|
||||||
"account.shares.modal.delete.description": "Do you really want to delete this share?",
|
"account.shares.modal.delete.description": "هل تريد حذف هذه المشاركة حقاً؟",
|
||||||
// END /account/shares
|
// END /account/shares
|
||||||
// /account/reverseShares
|
// /account/reverseShares
|
||||||
"account.reverseShares.title": "Reverse shares",
|
"account.reverseShares.title": "المشاركات العكسية",
|
||||||
"account.reverseShares.description": "A reverse share allows you to generate a unique URL that allows external users to create a share.",
|
"account.reverseShares.description": "تسمح لك المشاركة العكسية بإنشاء رابط فريد يسمح للمستخدمين الخارجيين بإنشاء مشاركة.",
|
||||||
"account.reverseShares.title.empty": "It's empty here 👀",
|
"account.reverseShares.title.empty": "المكان خالٍ هنا 👀",
|
||||||
"account.reverseShares.description.empty": "You don't have any reverse shares.",
|
"account.reverseShares.description.empty": "ليس لديك أي مشاركات عكسية.",
|
||||||
// showCreateReverseShareModal.tsx
|
// showCreateReverseShareModal.tsx
|
||||||
"account.reverseShares.modal.title": "Create reverse share",
|
"account.reverseShares.modal.title": "إنشاء مشاركة عكسية",
|
||||||
"account.reverseShares.modal.expiration.label": "Expiration",
|
"account.reverseShares.modal.expiration.label": "انتهاء الصلاحية",
|
||||||
"account.reverseShares.modal.expiration.minute-singular": "Minute",
|
"account.reverseShares.modal.expiration.minute-singular": "دقيقة",
|
||||||
"account.reverseShares.modal.expiration.minute-plural": "Minutes",
|
"account.reverseShares.modal.expiration.minute-plural": "دقائق",
|
||||||
"account.reverseShares.modal.expiration.hour-singular": "Hour",
|
"account.reverseShares.modal.expiration.hour-singular": "ساعة",
|
||||||
"account.reverseShares.modal.expiration.hour-plural": "Hours",
|
"account.reverseShares.modal.expiration.hour-plural": "ساعات",
|
||||||
"account.reverseShares.modal.expiration.day-singular": "Day",
|
"account.reverseShares.modal.expiration.day-singular": "يوم",
|
||||||
"account.reverseShares.modal.expiration.day-plural": "Days",
|
"account.reverseShares.modal.expiration.day-plural": "أيام",
|
||||||
"account.reverseShares.modal.expiration.week-singular": "Week",
|
"account.reverseShares.modal.expiration.week-singular": "أسبوع",
|
||||||
"account.reverseShares.modal.expiration.week-plural": "Weeks",
|
"account.reverseShares.modal.expiration.week-plural": "أسابيع",
|
||||||
"account.reverseShares.modal.expiration.month-singular": "Month",
|
"account.reverseShares.modal.expiration.month-singular": "شهر",
|
||||||
"account.reverseShares.modal.expiration.month-plural": "Months",
|
"account.reverseShares.modal.expiration.month-plural": "أشهر",
|
||||||
"account.reverseShares.modal.expiration.year-singular": "Year",
|
"account.reverseShares.modal.expiration.year-singular": "سنة",
|
||||||
"account.reverseShares.modal.expiration.year-plural": "Years",
|
"account.reverseShares.modal.expiration.year-plural": "سنوات",
|
||||||
"account.reverseShares.modal.max-size.label": "Max share size",
|
"account.reverseShares.modal.max-size.label": "الحد الأقصى لحجم المشاركة",
|
||||||
"account.reverseShares.modal.send-email": "Send email notification",
|
"account.reverseShares.modal.send-email": "أرسل إشعارًا بالبريد",
|
||||||
"account.reverseShares.modal.send-email.description": "Send an email notification when a share is created with this reverse share link.",
|
"account.reverseShares.modal.send-email.description": "إرسال إشعار بالبريد الإلكتروني عند إنشاء مشاركة باستخدام رابط المشاركة العكسي هذا.",
|
||||||
"account.reverseShares.modal.max-use.label": "Max uses",
|
"account.reverseShares.modal.simplified": "Simple mode",
|
||||||
"account.reverseShares.modal.max-use.description": "The maximum amount of times this URL can be used to create a share.",
|
"account.reverseShares.modal.simplified.description": "Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
|
||||||
"account.reverseShare.never-expires": "This reverse share will never expire.",
|
"account.reverseShares.modal.public-access": "Public access",
|
||||||
"account.reverseShare.expires-on": "This reverse share will expire on {expiration}.",
|
"account.reverseShares.modal.public-access.description": "Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
|
||||||
"account.reverseShares.table.no-shares": "No shares created yet",
|
"account.reverseShares.modal.max-use.label": "الحد الأقصى لعدد الاستخدامات",
|
||||||
"account.reverseShares.table.count.singular": "share",
|
"account.reverseShares.modal.max-use.description": "أقصى عدد من المرّات التي يمكن فيها استخدام هذا الرابط لإنشاء مشاركة.",
|
||||||
"account.reverseShares.table.count.plural": "shares",
|
"account.reverseShare.never-expires": "لن تنتهي صلاحية هذه المشاركة العكسية أبدًا.",
|
||||||
"account.reverseShares.table.shares": "Shares",
|
"account.reverseShare.expires-on": "هذه المشاركة العكسية ستنتهي صلاحيتها في {expiration}.",
|
||||||
"account.reverseShares.table.remaining": "Remaining uses",
|
"account.reverseShares.table.no-shares": "لم يتم إنشاء أي مشاركة بعد",
|
||||||
"account.reverseShares.table.max-size": "Max share size",
|
"account.reverseShares.table.count.singular": "مشاركة",
|
||||||
"account.reverseShares.table.expires": "Expires at",
|
"account.reverseShares.table.count.plural": "مشاركات",
|
||||||
"account.reverseShares.modal.reverse-share-link": "Reverse share link",
|
"account.reverseShares.table.shares": "مشاركات",
|
||||||
"account.reverseShares.modal.delete.title": "Delete reverse share",
|
"account.reverseShares.table.remaining": "الاستخدامات المتبقية",
|
||||||
"account.reverseShares.modal.delete.description": "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.",
|
"account.reverseShares.table.max-size": "الحد الأقصى لحجم المشاركة",
|
||||||
|
"account.reverseShares.table.expires": "تاريخ انتهاء الصلاحية",
|
||||||
|
"account.reverseShares.modal.reverse-share-link": "رابط المشاركة العكسية",
|
||||||
|
"account.reverseShares.modal.delete.title": "حذف المشاركة العكسية",
|
||||||
|
"account.reverseShares.modal.delete.description": "هل تريد حقاً حذف هذه المشاركة العكسية؟ إذا قمت بذلك، فسيتم حذف المشاركات المرتبطة بها أيضاً.",
|
||||||
// END /account/reverseShares
|
// END /account/reverseShares
|
||||||
// /admin
|
// /admin
|
||||||
"admin.title": "Administration",
|
"admin.title": "الإدارة",
|
||||||
"admin.button.users": "User management",
|
"admin.button.users": "إدارة المستخدم",
|
||||||
"admin.button.config": "Configuration",
|
"admin.button.shares": "إدارة المشاركة",
|
||||||
"admin.version": "Version",
|
"admin.button.config": "الإعدادات",
|
||||||
|
"admin.version": "الإصدار",
|
||||||
// END /admin
|
// END /admin
|
||||||
// /admin/users
|
// /admin/users
|
||||||
"admin.users.title": "User management",
|
"admin.users.title": "إدارة المستخدم",
|
||||||
"admin.users.table.username": "Username",
|
"admin.users.table.username": "اسم المستخدم",
|
||||||
"admin.users.table.email": "Email",
|
"admin.users.table.email": "البريد",
|
||||||
"admin.users.table.admin": "Admin",
|
"admin.users.table.admin": "المدير",
|
||||||
"admin.users.edit.update.title": "Update user {username}",
|
"admin.users.edit.update.title": "تحديث المستخدم {username}",
|
||||||
"admin.users.edit.update.admin-privileges": "Admin privileges",
|
"admin.users.edit.update.admin-privileges": "صلاحيات المدير",
|
||||||
"admin.users.edit.update.change-password.title": "Change password",
|
"admin.users.edit.update.change-password.title": "تغيير كلمة السر",
|
||||||
"admin.users.edit.update.change-password.field": "New password",
|
"admin.users.edit.update.change-password.field": "كلمة السر الجديدة",
|
||||||
"admin.users.edit.update.change-password.button": "Save new password",
|
"admin.users.edit.update.change-password.button": "حفظ كلمة السر الجديدة",
|
||||||
"admin.users.edit.update.notify.password.success": "Password changed successfully",
|
"admin.users.edit.update.notify.password.success": "غيرت كلمة السر بنجاح",
|
||||||
"admin.users.edit.delete.title": "Delete user {username}",
|
"admin.users.edit.delete.title": "حذف المستخدم {username}",
|
||||||
"admin.users.edit.delete.description": "Do you really want to delete this user and all his shares?",
|
"admin.users.edit.delete.description": "هل تريد حقاً حذف هذا المستخدم وكل مشاركاته؟",
|
||||||
// showCreateUserModal.tsx
|
// showCreateUserModal.tsx
|
||||||
"admin.users.modal.create.title": "Create user",
|
"admin.users.modal.create.title": "أنشئ مستخدمًا",
|
||||||
"admin.users.modal.create.username": "Username",
|
"admin.users.modal.create.username": "اسم المستخدم",
|
||||||
"admin.users.modal.create.email": "Email",
|
"admin.users.modal.create.email": "البريد",
|
||||||
"admin.users.modal.create.password": "Password",
|
"admin.users.modal.create.password": "كلمة السر",
|
||||||
"admin.users.modal.create.manual-password": "Set password manually",
|
"admin.users.modal.create.manual-password": "تعيين كلمة السر يدوياً",
|
||||||
"admin.users.modal.create.manual-password.description": "If not checked, the user will receive an email with a link to set their password.",
|
"admin.users.modal.create.manual-password.description": "بدون هذا الخيار، سيتلقى المستخدم رسالة بريد إلكتروني فيها رابط لتعيين كلمة السر الخاصة به.",
|
||||||
"admin.users.modal.create.admin": "Admin privileges",
|
"admin.users.modal.create.admin": "صلاحيات المدير",
|
||||||
"admin.users.modal.create.admin.description": "If checked, the user will be able to access the admin panel.",
|
"admin.users.modal.create.admin.description": "مع هذا الخيار، سيتمكن المستخدم من الدخول إلى لوحة الإدارة.",
|
||||||
// END /admin/users
|
// END /admin/users
|
||||||
|
// /admin/shares
|
||||||
|
"admin.shares.title": "إدارة المشاركة",
|
||||||
|
"admin.shares.table.id": "معرّف المشاركة",
|
||||||
|
"admin.shares.table.username": "المُنشئ",
|
||||||
|
"admin.shares.table.visitors": "الزوار",
|
||||||
|
"admin.shares.table.expires": "تاريخ انتهاء الصلاحية",
|
||||||
|
"admin.shares.edit.delete.title": "حذف المشاركة {id}",
|
||||||
|
"admin.shares.edit.delete.description": "هل تريد حذف هذه المشاركة حقاً؟",
|
||||||
|
// END /admin/shares
|
||||||
// /upload
|
// /upload
|
||||||
"upload.title": "Upload",
|
"upload.title": "رفع",
|
||||||
"upload.notify.generic-error": "An error occurred while finishing your share.",
|
"upload.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
|
||||||
"upload.notify.count-failed": "{count} files failed to upload. Trying again.",
|
"upload.notify.count-failed": "فشل رفع {count} ملفات. تجري المحاولة مجددًا.",
|
||||||
// Dropzone.tsx
|
// Dropzone.tsx
|
||||||
"upload.dropzone.title": "Upload files",
|
"upload.dropzone.title": "رفع الملفات",
|
||||||
"upload.dropzone.description": "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.",
|
"upload.dropzone.description": "اسحب الملفات إلى هنا لبدء مشاركتك. يمكننا فقط قبول الملفات التي لا يزيد حجمها عن {maxSize} بالمجمل.",
|
||||||
"upload.dropzone.notify.file-too-big": "Your files exceed the maximum share size of {maxSize}.",
|
"upload.dropzone.notify.file-too-big": "تتجاوز ملفاتك الحجم الأقصى للمشاركة والذي هو {maxSize}.",
|
||||||
// FileList.tsx
|
// FileList.tsx
|
||||||
"upload.filelist.name": "Name",
|
"upload.filelist.name": "الاسم",
|
||||||
"upload.filelist.size": "Size",
|
"upload.filelist.size": "الحجم",
|
||||||
// showCreateUploadModal.tsx
|
// showCreateUploadModal.tsx
|
||||||
"upload.modal.title": "Create Share",
|
"upload.modal.title": "إنشاء مشاركة",
|
||||||
"upload.modal.link.error.invalid": "Can only contain letters, numbers, underscores, and hyphens",
|
"upload.modal.link.error.invalid": "يمكن أن يحتوي فقط على الأحرف والأرقام والشرطات السفلية والواصلات",
|
||||||
"upload.modal.link.error.taken": "This link is already in use",
|
"upload.modal.link.error.taken": "هذا الرابط مستخدم مسبقاً",
|
||||||
"upload.modal.not-signed-in": "You're not signed in",
|
"upload.modal.not-signed-in": "لم تقم بتسجيل الدخول",
|
||||||
"upload.modal.not-signed-in-description": "You will be unable to delete your share manually and view the visitor count.",
|
"upload.modal.not-signed-in-description": "لن تتمكن من حذف مشاركتك يدوياً أو عرض عدد الزوار.",
|
||||||
"upload.modal.expires.never": "never",
|
"upload.modal.expires.never": "أبدًا",
|
||||||
"upload.modal.expires.never-long": "Never Expires",
|
"upload.modal.expires.never-long": "لا تنتهي الصلاحية أبداً",
|
||||||
"upload.modal.expires.error.too-long": "Expiration exceeds maximum expiration date of {max}.",
|
"upload.modal.expires.error.too-long": "انتهاء الصلاحية يتجاوز الحد الأقصى لتاريخ انتهاء الصلاحية والذي هو {max}.",
|
||||||
"upload.modal.link.label": "Link",
|
"upload.modal.link.label": "الرابط",
|
||||||
"upload.modal.expires.label": "Expiration",
|
"upload.modal.expires.label": "انتهاء الصلاحية",
|
||||||
"upload.modal.expires.minute-singular": "Minute",
|
"upload.modal.expires.minute-singular": "دقيقة",
|
||||||
"upload.modal.expires.minute-plural": "Minutes",
|
"upload.modal.expires.minute-plural": "دقائق",
|
||||||
"upload.modal.expires.hour-singular": "Hour",
|
"upload.modal.expires.hour-singular": "ساعة",
|
||||||
"upload.modal.expires.hour-plural": "Hours",
|
"upload.modal.expires.hour-plural": "ساعات",
|
||||||
"upload.modal.expires.day-singular": "Day",
|
"upload.modal.expires.day-singular": "يوم",
|
||||||
"upload.modal.expires.day-plural": "Days",
|
"upload.modal.expires.day-plural": "أيام",
|
||||||
"upload.modal.expires.week-singular": "Week",
|
"upload.modal.expires.week-singular": "أسبوع",
|
||||||
"upload.modal.expires.week-plural": "Weeks",
|
"upload.modal.expires.week-plural": "أسابيع",
|
||||||
"upload.modal.expires.month-singular": "Month",
|
"upload.modal.expires.month-singular": "شهر",
|
||||||
"upload.modal.expires.month-plural": "Months",
|
"upload.modal.expires.month-plural": "أشهر",
|
||||||
"upload.modal.expires.year-singular": "Year",
|
"upload.modal.expires.year-singular": "سنة",
|
||||||
"upload.modal.expires.year-plural": "Years",
|
"upload.modal.expires.year-plural": "سنوات",
|
||||||
"upload.modal.accordion.description.title": "Description",
|
"upload.modal.accordion.name-and-description.title": "الاسم والوصف",
|
||||||
"upload.modal.accordion.description.placeholder": "Note for the recipients of this share",
|
"upload.modal.accordion.name-and-description.name.placeholder": "الاسم",
|
||||||
"upload.modal.accordion.email.title": "Email recipients",
|
"upload.modal.accordion.name-and-description.description.placeholder": "ملاحظة لمستقبلي هذه المشاركة",
|
||||||
"upload.modal.accordion.email.placeholder": "Enter email recipients",
|
"upload.modal.accordion.email.title": "مستلمو البريد الإلكتروني",
|
||||||
"upload.modal.accordion.email.invalid-email": "Invalid email address",
|
"upload.modal.accordion.email.placeholder": "أدخل مستلمي البريد",
|
||||||
"upload.modal.accordion.security.title": "Security options",
|
"upload.modal.accordion.email.invalid-email": "عنوان البريد غير صحيح",
|
||||||
"upload.modal.accordion.security.password.label": "Password protection",
|
"upload.modal.accordion.security.title": "خيارات الأمان",
|
||||||
"upload.modal.accordion.security.password.placeholder": "No password",
|
"upload.modal.accordion.security.password.label": "الحماية بكلمة السر",
|
||||||
"upload.modal.accordion.security.max-views.label": "Maximum views",
|
"upload.modal.accordion.security.password.placeholder": "لا توجد كلمة سر",
|
||||||
"upload.modal.accordion.security.max-views.placeholder": "No limit",
|
"upload.modal.accordion.security.max-views.label": "الحد الأقصى للمشاهدات",
|
||||||
|
"upload.modal.accordion.security.max-views.placeholder": "لا يوجد حد",
|
||||||
// showCompletedUploadModal.tsx
|
// showCompletedUploadModal.tsx
|
||||||
"upload.modal.completed.never-expires": "This share will never expire.",
|
"upload.modal.completed.never-expires": "لن تنتهي صلاحية هذه المشاركة أبدًا.",
|
||||||
"upload.modal.completed.expires-on": "This share will expire on {expiration}.",
|
"upload.modal.completed.expires-on": "هذه المشاركة ستنتهي صلاحيتها في {expiration}.",
|
||||||
"upload.modal.completed.share-ready": "Share ready",
|
"upload.modal.completed.share-ready": "المشاركة جاهزة",
|
||||||
|
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
|
||||||
// END /upload
|
// END /upload
|
||||||
// /share/[id]
|
// /share/[id]
|
||||||
"share.title": "Share {shareId}",
|
"share.title": "المشاركة {shareId}",
|
||||||
"share.description": "Look what I've shared with you!",
|
"share.description": "انظر ما الذي شاركته معك!",
|
||||||
"share.error.visitor-limit-exceeded.title": "Visitor limit exceeded",
|
"share.error.visitor-limit-exceeded.title": "تم تجاوز حد المشاهدات",
|
||||||
"share.error.visitor-limit-exceeded.description": "The visitor limit from this share has been exceeded.",
|
"share.error.visitor-limit-exceeded.description": "تم تجاوز الحد الأقصى لزوار هذه المشاركة.",
|
||||||
"share.error.removed.title": "Share removed",
|
"share.error.removed.title": "تمت إزالة المشاركة",
|
||||||
"share.error.not-found.title": "Share not found",
|
"share.error.not-found.title": "المشاركة غير موجودة",
|
||||||
"share.error.not-found.description": "The share you're looking for doesn't exist.",
|
"share.error.not-found.description": "المشاركة التي تبحث عنها غير موجودة.",
|
||||||
"share.modal.password.title": "Password required",
|
"share.error.access-denied.title": "Private share",
|
||||||
"share.modal.password.description": "To access this share please enter the password for the share.",
|
"share.error.access-denied.description": "The current account does not have permission to access this share",
|
||||||
"share.modal.password": "Password",
|
"share.modal.password.title": "كلمة السر مطلوبة",
|
||||||
"share.modal.error.invalid-password": "Invalid password",
|
"share.modal.password.description": "للوصول إلى هذه المشاركة الرجاء إدخال كلمة سر المشاركة.",
|
||||||
"share.button.download-all": "Download all",
|
"share.modal.password": "كلمة السر",
|
||||||
"share.notify.download-all-preparing": "The share is preparing. Try again in a few minutes.",
|
"share.modal.error.invalid-password": "كلمة السر غير صحيحة",
|
||||||
"share.modal.file-link": "File link",
|
"share.button.download-all": "تنزيل الكل",
|
||||||
"share.table.name": "Name",
|
"share.notify.download-all-preparing": "يتم تحضير المشاركة. حاول مرة أخرى في بضع دقائق.",
|
||||||
"share.table.size": "Size",
|
"share.modal.file-link": "رابط الملف",
|
||||||
"share.modal.file-preview.error.not-supported.title": "Preview not supported",
|
"share.table.name": "الاسم",
|
||||||
"share.modal.file-preview.error.not-supported.description": "A preview for this file type is unsupported. Please download the file to view it.",
|
"share.table.size": "الحجم",
|
||||||
|
"share.modal.file-preview.error.not-supported.title": "المعاينة غير مدعومة",
|
||||||
|
"share.modal.file-preview.error.not-supported.description": "معاينة هذا النوع من الملفات غير مدعومة. الرجاء تنزيل الملف لعرضه.",
|
||||||
// END /share/[id]
|
// END /share/[id]
|
||||||
// /share/[id]/edit
|
// /share/[id]/edit
|
||||||
"share.edit.title": "Edit {shareId}",
|
"share.edit.title": "تحرير {shareId}",
|
||||||
"share.edit.append-upload": "Append file",
|
"share.edit.append-upload": "إضافة ملف",
|
||||||
"share.edit.notify.generic-error": "An error occurred while finishing your share.",
|
"share.edit.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
|
||||||
"share.edit.notify.save-success": "Share updated successfully",
|
"share.edit.notify.save-success": "تم تحديث المشاركة بنجاح",
|
||||||
// END /share/[id]/edit
|
// END /share/[id]/edit
|
||||||
// /admin/config
|
// /admin/config
|
||||||
"admin.config.title": "Configuration",
|
"admin.config.title": "الإعدادات",
|
||||||
"admin.config.category.general": "General",
|
"admin.config.category.general": "عام",
|
||||||
"admin.config.category.share": "Share",
|
"admin.config.category.share": "مشاركة",
|
||||||
"admin.config.category.email": "Email",
|
"admin.config.category.email": "البريد",
|
||||||
"admin.config.category.smtp": "SMTP",
|
"admin.config.category.smtp": "بروتوكول نقل البريد البسيط SMTP",
|
||||||
"admin.config.category.oauth": "Social Login",
|
"admin.config.category.oauth": "الدخول بحساب تواصل اجتماعي",
|
||||||
"admin.config.general.app-name": "App name",
|
"admin.config.general.app-name": "اسم التطبيق",
|
||||||
"admin.config.general.app-name.description": "Name of the application",
|
"admin.config.general.app-name.description": "اسم التطبيق",
|
||||||
"admin.config.general.app-url": "App URL",
|
"admin.config.general.app-url": "رابط التطبيق",
|
||||||
"admin.config.general.app-url.description": "On which URL Pingvin Share is available",
|
"admin.config.general.app-url.description": "الرابط الذي تكون مشاركة Pingvin صالحة عليه",
|
||||||
"admin.config.general.show-home-page": "Show home page",
|
"admin.config.general.show-home-page": "إظهار الصفحة الرئيسية",
|
||||||
"admin.config.general.show-home-page.description": "Whether to show the home page",
|
"admin.config.general.show-home-page.description": "تحديد ما إذا كان سيتم عرض الصفحة الرئيسية",
|
||||||
"admin.config.general.logo": "Logo",
|
"admin.config.general.session-duration": "مدة الجلسة",
|
||||||
"admin.config.general.logo.description": "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.",
|
"admin.config.general.session-duration.description": "الوقت بالساعات الذي يجب على المستخدم بعده إعادة تسجيل الدخول (الافتراضي: 3 أشهر).",
|
||||||
"admin.config.general.logo.placeholder": "Pick image",
|
"admin.config.general.logo": "الشعار",
|
||||||
"admin.config.email.enable-share-email-recipients": "Enable share email recipients",
|
"admin.config.general.logo.description": "يمكنك تغيير شعارك عن طريق تحميل صورة جديدة. يجب أن تكون الصورة PNG ويجب أن يكون تنسيقها 1:1.",
|
||||||
"admin.config.email.enable-share-email-recipients.description": "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
"admin.config.general.logo.placeholder": "اختر صورة",
|
||||||
"admin.config.email.share-recipients-subject": "Share recipients subject",
|
"admin.config.email.enable-share-email-recipients": "تفعيل مستلمي البريد الإلكتروني لهذه المشاركة",
|
||||||
"admin.config.email.share-recipients-subject.description": "Subject of the email which gets sent to the share recipients.",
|
"admin.config.email.enable-share-email-recipients.description": "السماح لرسائل البريد بأن تُشارك المستلمين. لا تفعّل هذا الخيار ما لم تفعّل SMTP مسبقًا.",
|
||||||
"admin.config.email.share-recipients-message": "Share recipients message",
|
"admin.config.email.share-recipients-subject": "عنوان الرسالة لمستلمي المشاركة",
|
||||||
"admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available 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 The variables will be replaced with the actual value.",
|
"admin.config.email.share-recipients-subject.description": "عنوان البريد الذي سيُرسَل لمستقبِلي المشاركة.",
|
||||||
"admin.config.email.reverse-share-subject": "Reverse share subject",
|
"admin.config.email.share-recipients-message": "رسالتك لمستقبِلي المشاركة",
|
||||||
"admin.config.email.reverse-share-subject.description": "Subject of the email which gets sent when someone created a share with your reverse share link.",
|
"admin.config.email.share-recipients-message.description": "الرسالة التي ستُرسل لمستقبِلي المشاركة. يمكنك استخدام هذه المتغيرات:\n{creator} - اسم المستخدم الذي أنشأ المشاركة\n{shareUrl} - رابط المشاركة\n{desc} - وصف المشاركة\n{expires} - تاريخ انتهاء صلاحية المشاركة\nستتم كتابة قيم هذه المتغيرات تلقائيًا.",
|
||||||
"admin.config.email.reverse-share-message": "Reverse share message",
|
"admin.config.email.reverse-share-subject": "عنوان المشاركة العكسية",
|
||||||
"admin.config.email.reverse-share-message.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.",
|
"admin.config.email.reverse-share-subject.description": "عنوان البريد الذي سيُرسل عندما يُنشئ شخص ما مشاركةً باستخدام رابط المشاركة العكسية الخاص بك.",
|
||||||
"admin.config.email.reset-password-subject": "Reset password subject",
|
"admin.config.email.reverse-share-message": "رسالة المشاركة العكسية",
|
||||||
"admin.config.email.reset-password-subject.description": "Subject of the email which gets sent when a user requests a password reset.",
|
"admin.config.email.reverse-share-message.description": "الرسالة التي ستُرسل عندما يُنشئ شخص ما مشاركة باستخدام رابط المشاركة الخاص بك. سيُوضع اسم المُنشِئ ورابط المشاركة مكان {shareUrl}.",
|
||||||
"admin.config.email.reset-password-message": "Reset password message",
|
"admin.config.email.reset-password-subject": "رسالة إعادة تعيين كلمة السر",
|
||||||
"admin.config.email.reset-password-message.description": "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
"admin.config.email.reset-password-subject.description": "عنوان البريد الذي سيُرسل حين يطلب مستخدم ما إعادة تعيين كلمة سرّه.",
|
||||||
"admin.config.email.invite-subject": "Invite subject",
|
"admin.config.email.reset-password-message": "رسالة إعادة تعيين كلمة السر",
|
||||||
"admin.config.email.invite-subject.description": "Subject of the email which gets sent when an admin invites a user.",
|
"admin.config.email.reset-password-message.description": "الرسالة التي ستُرسل عندما يطلب المستخدم إعادة تعيين كلمة سرّه. سيُوضع رابط إعادة تعيين كلمة السر مكان {url}.",
|
||||||
"admin.config.email.invite-message": "Invite message",
|
"admin.config.email.invite-subject": "عنوان الدعوة",
|
||||||
"admin.config.email.invite-message.description": "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.",
|
"admin.config.email.invite-subject.description": "عنوان البريد الذي سيُرسل عندما يقوم المشرف بدعوة مستخدم ما.",
|
||||||
"admin.config.share.allow-registration": "Allow registration",
|
"admin.config.email.invite-message": "رسالة الدعوة",
|
||||||
"admin.config.share.allow-registration.description": "Whether registration is allowed",
|
"admin.config.email.invite-message.description": "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL, {email} with the email and {password} with the password of the user.",
|
||||||
"admin.config.share.allow-unauthenticated-shares": "Allow unauthenticated shares",
|
"admin.config.share.allow-registration": "السماح بالتسجيل",
|
||||||
"admin.config.share.allow-unauthenticated-shares.description": "Whether unauthenticated users can create shares",
|
"admin.config.share.allow-registration.description": "إتاحة تسجيل حساب جديد",
|
||||||
"admin.config.share.max-expiration": "Max expiration",
|
"admin.config.share.allow-unauthenticated-shares": "السماح بالمشاركات غير المصادق عليها",
|
||||||
"admin.config.share.max-expiration.description": "Maximum share expiration in hours. Set to 0 to allow unlimited expiration.",
|
"admin.config.share.allow-unauthenticated-shares.description": "إتاحة إنشاء المشاركات للمستخدمين غير الموثقين",
|
||||||
"admin.config.share.max-size": "Max size",
|
"admin.config.share.max-expiration": "أبعد زمن لانتهاء الصلاحية",
|
||||||
"admin.config.share.max-size.description": "Maximum share size in bytes",
|
"admin.config.share.max-expiration.description": "أطول زمن لانتهاء صلاحية المشاركات بالساعات. الصفر يعني أن المشاركة لن تنتهي صلاحيتها.",
|
||||||
"admin.config.share.zip-compression-level": "Zip compression level",
|
"admin.config.share.max-size": "أكبر حجم",
|
||||||
"admin.config.share.zip-compression-level.description": "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ",
|
"admin.config.share.max-size.description": "أكبر حجم للمشاركة مقيسًا بالبايت",
|
||||||
"admin.config.smtp.enabled": "Enabled",
|
"admin.config.share.zip-compression-level": "مستوى ضغط الZip",
|
||||||
"admin.config.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.",
|
"admin.config.share.zip-compression-level.description": "ضبط الميزان بين حجم الملف وسرعة الضغط. يمكنك إدخال قيم بين 0 إلى 9، حيث 0 تعني بدون ضغط و9 تعني أقصى ضغط. ",
|
||||||
"admin.config.smtp.host": "Host",
|
"admin.config.share.chunk-size": "حجم القطعة",
|
||||||
"admin.config.smtp.host.description": "Host of the SMTP server",
|
"admin.config.share.chunk-size.description": "ضبط حجم القطعة (بالبايت) لملفاتك المرفوعة للموازنة بين الكفاءة والفعالية حسب قوة اتصالك بالإنترنت. القطع الأصغر يمكن أن ترفع معدل النجاح في حال كان اتصالك بالإنترنت غير مستقر، بينما القطع الأكبر يمكنها أن تُسرّع رفع الملفات في حال كان الاتصال بالإنترنت مستقرًا.",
|
||||||
"admin.config.smtp.port": "Port",
|
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
|
||||||
"admin.config.smtp.port.description": "Port of the SMTP server",
|
"admin.config.share.auto-open-share-modal.description": "The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.",
|
||||||
"admin.config.smtp.email": "Email",
|
"admin.config.smtp.enabled": "مفعل",
|
||||||
"admin.config.smtp.email.description": "Email address which the emails get sent from",
|
"admin.config.smtp.enabled.description": "تفعيل الـSMTP. لا تفعّله إلا إذا قمت بإدخال المضيف، والمنفذ، والبريد الإلكتروني، واسم المستخدم، وكلمة السر لخادم الـSMTP.",
|
||||||
"admin.config.smtp.username": "Username",
|
"admin.config.smtp.host": "المُضيف",
|
||||||
"admin.config.smtp.username.description": "Username of the SMTP server",
|
"admin.config.smtp.host.description": "مضيف خادم الـSMTP",
|
||||||
"admin.config.smtp.password": "Password",
|
"admin.config.smtp.port": "المنفذ",
|
||||||
"admin.config.smtp.password.description": "Password of the SMTP server",
|
"admin.config.smtp.port.description": "منفذ خادم الـSMTP",
|
||||||
"admin.config.smtp.button.test": "Send test email",
|
"admin.config.smtp.email": "البريد الإلكتروني",
|
||||||
"admin.config.oauth.allow-registration": "Allow registration",
|
"admin.config.smtp.email.description": "عنوان البريد الذي ستُرسَل الرسائل منه",
|
||||||
"admin.config.oauth.allow-registration.description": "Allow users to register via social login",
|
"admin.config.smtp.username": "اسم المستخدم",
|
||||||
"admin.config.oauth.ignore-totp": "Ignore TOTP",
|
"admin.config.smtp.username.description": "اسم المستخدم لخادم الـSMTP",
|
||||||
"admin.config.oauth.ignore-totp.description": "Whether to ignore TOTP when user using social login",
|
"admin.config.smtp.password": "كلمة السر",
|
||||||
|
"admin.config.smtp.password.description": "كلمة السر لخادم الـSMTP",
|
||||||
|
"admin.config.smtp.button.test": "إرسال رسالة بريد تجريبية",
|
||||||
|
"admin.config.smtp.allow-unauthorized-certificates": "Trust unauthorized SMTP server certificates",
|
||||||
|
"admin.config.smtp.allow-unauthorized-certificates.description": "Only set this to true if you need to trust self signed certificates.",
|
||||||
|
"admin.config.oauth.allow-registration": "السماح بتسجيل الحسابات الجديدة",
|
||||||
|
"admin.config.oauth.allow-registration.description": "السماح للمستخدمين بالدخول بواسطة حساباتهم الاجتماعية",
|
||||||
|
"admin.config.oauth.ignore-totp": "تجاهل TOTP",
|
||||||
|
"admin.config.oauth.ignore-totp.description": "تجاهل TOTP إذا دخل المستخدم بحسابه الاجتماعي",
|
||||||
|
"admin.config.oauth.disable-password": "تعطيل تسجيل الدخول باستخدام كلمة السر",
|
||||||
|
"admin.config.oauth.disable-password.description": "Whether to disable password login\nMake sure that an OAuth provider is properly configured before activating this configuration to avoid being locked out.",
|
||||||
"admin.config.oauth.github-enabled": "GitHub",
|
"admin.config.oauth.github-enabled": "GitHub",
|
||||||
"admin.config.oauth.github-enabled.description": "Whether GitHub login is enabled",
|
"admin.config.oauth.github-enabled.description": "تفعيل خيار الدخول بحساب GitHub",
|
||||||
"admin.config.oauth.github-client-id": "GitHub Client ID",
|
"admin.config.oauth.github-client-id": "GitHub Client ID",
|
||||||
"admin.config.oauth.github-client-id.description": "Client ID of the GitHub OAuth app",
|
"admin.config.oauth.github-client-id.description": "معرف العميل لتطبيق GitHub OAuth",
|
||||||
"admin.config.oauth.github-client-secret": "GitHub Client secret",
|
"admin.config.oauth.github-client-secret": "الرمز السرّي لـGitHub Client",
|
||||||
"admin.config.oauth.github-client-secret.description": "Client secret of the GitHub OAuth app",
|
"admin.config.oauth.github-client-secret.description": "الرّمز السرّي للعميل لتطبيق GitHub OAuth",
|
||||||
"admin.config.oauth.google-enabled": "Google",
|
"admin.config.oauth.google-enabled": "Google",
|
||||||
"admin.config.oauth.google-enabled.description": "Whether Google login is enabled",
|
"admin.config.oauth.google-enabled.description": "تفعيل خيار الدخول بحساب Google",
|
||||||
"admin.config.oauth.google-client-id": "Google Client ID",
|
"admin.config.oauth.google-client-id": "Google Client ID",
|
||||||
"admin.config.oauth.google-client-id.description": "Client ID of the Google OAuth app",
|
"admin.config.oauth.google-client-id.description": "معرف العميل لتطبيق Google OAuth",
|
||||||
"admin.config.oauth.google-client-secret": "Google Client secret",
|
"admin.config.oauth.google-client-secret": "الرمز السرّي لـ Google Client",
|
||||||
"admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app",
|
"admin.config.oauth.google-client-secret.description": "الرّمز السرّي للعميل لتطبيق Google OAuth",
|
||||||
"admin.config.oauth.microsoft-enabled": "Microsoft",
|
"admin.config.oauth.microsoft-enabled": "Microsoft",
|
||||||
"admin.config.oauth.microsoft-enabled.description": "Whether Microsoft login is enabled",
|
"admin.config.oauth.microsoft-enabled.description": "تفعيل خيار الدخول بحساب Microsoft",
|
||||||
"admin.config.oauth.microsoft-tenant": "Microsoft Tenant",
|
"admin.config.oauth.microsoft-tenant": "Microsoft Tenant",
|
||||||
"admin.config.oauth.microsoft-tenant.description": "Tenant ID of the Microsoft OAuth app\ncommon: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.\nconsumers: Only users with a personal Microsoft account can sign in to the application.\ndomain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.",
|
"admin.config.oauth.microsoft-tenant.description": "معرف Tenant لتطبيق مايكروسوفت OAuth\nالشائع: يمكن للمستخدمين الذين لديهم حساب مايكروسوفت شخصي وحساب عمل أو مدرسة من معرف Microsoft Entra أن يسجلوا الدخول إلى التطبيق. بالنسبة المؤسسات: يمكن فقط للمستخدمين الذين لديهم حسابات عمل أو مدرسة من Microsoft Entra ID تسجيل الدخول إلى التطبيق.\nالمستهلكين: يمكن فقط للمستخدمين الذين لديهم حساب مايكروسوفت الشخصي تسجيل الدخول إلى التطبيق.\nاسم نطاق مستأجر Microsoft Entra أو معرف المستأجر بتنسيق GUID: يمكن فقط للمستخدمين من مستأجر Microsoft Entra محدد (أعضاء الإدارة الذين لديهم حساب عمل أو مدرسة أو ضيوف الإدارة الذين لديهم حساب شخصي لمايكروسوفت) تسجيل الدخول إلى التطبيق.",
|
||||||
"admin.config.oauth.microsoft-client-id": "Microsoft Client ID",
|
"admin.config.oauth.microsoft-client-id": "Microsoft Client ID",
|
||||||
"admin.config.oauth.microsoft-client-id.description": "Client ID of the Microsoft OAuth app",
|
"admin.config.oauth.microsoft-client-id.description": "معرف العميل لتطبيق Microsoft OAuth",
|
||||||
"admin.config.oauth.microsoft-client-secret": "Microsoft Client secret",
|
"admin.config.oauth.microsoft-client-secret": "الرمز السرّي لـMicrosoft Client",
|
||||||
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
"admin.config.oauth.microsoft-client-secret.description": "الرّمز السرّي للعميل لتطبيق Microsoft OAuth",
|
||||||
"admin.config.oauth.discord-enabled": "Discord",
|
"admin.config.oauth.discord-enabled": "Discord",
|
||||||
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
|
"admin.config.oauth.discord-enabled.description": "تفعيل خيار الدخول بحساب Discord",
|
||||||
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
|
"admin.config.oauth.discord-limited-guild": "مُعرِّف خادم Discord المحدود",
|
||||||
"admin.config.oauth.discord-limited-guild.description": "Limit signing in to users in a specific server. Leave it blank to disable.",
|
"admin.config.oauth.discord-limited-guild.description": "حصر تسجيل الدخول على المستخدمين الموجودين في خادم محدّد. اترك هذا الخيار فارغًا لتعطيله.",
|
||||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||||
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
|
"admin.config.oauth.discord-client-id.description": "معرف العميل لتطبيق Discord OAuth",
|
||||||
"admin.config.oauth.discord-client-secret": "Discord Client secret",
|
"admin.config.oauth.discord-client-secret": "الرمز السرّي لـDiscord Client",
|
||||||
"admin.config.oauth.discord-client-secret.description": "Client secret of the Discord OAuth app",
|
"admin.config.oauth.discord-client-secret.description": "الرّمز السرّي للعميل لتطبيق Discord OAuth",
|
||||||
"admin.config.oauth.oidc-enabled": "OpenID Connect",
|
"admin.config.oauth.oidc-enabled": "OpenID Connect",
|
||||||
"admin.config.oauth.oidc-enabled.description": "Whether OpenID Connect login is enabled",
|
"admin.config.oauth.oidc-enabled.description": "تفعيل الدخول باستخدام OpenID Connect",
|
||||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID Connect OAuth app",
|
"admin.config.oauth.oidc-discovery-uri.description": "رابط الاستكشاف لتطبيق OpenID Connect OAuth",
|
||||||
|
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||||
|
"admin.config.oauth.oidc-sign-out.description": "Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||||
"admin.config.oauth.oidc-username-claim.description": "Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
"admin.config.oauth.oidc-username-claim.description": "طلب اسم المستخدم في رمز معرف OpenID Connect. إذا كنت لا تعرف معنى هذا الإعداد، اتركه فارغًا.",
|
||||||
|
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||||
|
"admin.config.oauth.oidc-role-path.description": "Must be a valid JMES path referencing an array of roles. " + "Managing access rights using OpenID Connect roles is only recommended if no other identity provider is configured and password login is disabled. " + "Leave it blank if you don't know what this config is.",
|
||||||
|
"admin.config.oauth.oidc-role-general-access": "OpenID Connect role for general access",
|
||||||
|
"admin.config.oauth.oidc-role-general-access.description": "Role required for general access. Must be present in a user’s roles for them to log in. " + "Leave it blank if you don't know what this config is.",
|
||||||
|
"admin.config.oauth.oidc-role-admin-access": "OpenID Connect role for admin access",
|
||||||
|
"admin.config.oauth.oidc-role-admin-access.description": "Role required for administrative access. Must be present in a user’s roles for them to access the admin panel. " + "Leave it blank if you don't know what this config is.",
|
||||||
"admin.config.oauth.oidc-client-id": "OpenID Connect Client ID",
|
"admin.config.oauth.oidc-client-id": "OpenID Connect Client ID",
|
||||||
"admin.config.oauth.oidc-client-id.description": "Client ID of the OpenID Connect OAuth app",
|
"admin.config.oauth.oidc-client-id.description": "معرف العميل لتطبيق OpenID Connect OAuth",
|
||||||
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
|
||||||
"admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID Connect OAuth app",
|
"admin.config.oauth.oidc-client-secret.description": "الرّمز السرّي للعميل لتطبيق OpenID Connect OAuth",
|
||||||
|
"admin.config.category.ldap": "LDAP",
|
||||||
|
"admin.config.ldap.enabled": "Enabled LDAP",
|
||||||
|
"admin.config.ldap.enabled.description": "Use LDAP authentication for user login",
|
||||||
|
"admin.config.ldap.url": "Server URL",
|
||||||
|
"admin.config.ldap.url.description": "URL of the LDAP server",
|
||||||
|
"admin.config.ldap.bind-dn": "Bind DN",
|
||||||
|
"admin.config.ldap.bind-dn.description": "Default user which will be used to execute the user search",
|
||||||
|
"admin.config.ldap.bind-password": "Bind password",
|
||||||
|
"admin.config.ldap.bind-password.description": "Password for the user search user",
|
||||||
|
"admin.config.ldap.search-base": "User base",
|
||||||
|
"admin.config.ldap.search-base.description": "Base location, where the user search will be performed",
|
||||||
|
"admin.config.ldap.search-query": "User query",
|
||||||
|
"admin.config.ldap.search-query.description": "The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.",
|
||||||
|
"admin.config.ldap.admin-groups": "Admin group",
|
||||||
|
"admin.config.ldap.admin-groups.description": "Group required for administrative access.",
|
||||||
|
"admin.config.ldap.field-name-member-of": "User groups attribute name",
|
||||||
|
"admin.config.ldap.field-name-member-of.description": "LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.",
|
||||||
|
"admin.config.ldap.field-name-email": "User email attribute name",
|
||||||
|
"admin.config.ldap.field-name-email.description": "LDAP attribute name for the email of an user.",
|
||||||
// 404
|
// 404
|
||||||
"404.description": "Oops this page doesn't exist.",
|
"404.description": "هذه الصفحة غير موجودة.",
|
||||||
"404.button.home": "Bring me back home",
|
"404.button.home": "أعدني للصفحة الرئيسية",
|
||||||
// error
|
// error
|
||||||
"error.title": "Error",
|
"error.title": "خطأ",
|
||||||
"error.description": "Oops!",
|
"error.description": "عذرًا!",
|
||||||
"error.button.back": "Go back",
|
"error.button.back": "العودة",
|
||||||
"error.msg.default": "Something went wrong.",
|
"error.msg.default": "حَدث خطأ ما.",
|
||||||
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
"error.msg.access_denied": "قمت بإلغاء عملية المصادقة، الرجاء المحاولة مرة أخرى.",
|
||||||
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
"error.msg.expired_token": "استغرقت عملية المصادقة وقتًا طويلًا، يرجى المحاولة مرة أخرى.",
|
||||||
"error.msg.invalid_token": "Internal Error",
|
"error.msg.invalid_token": "خطأ داخلي",
|
||||||
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
"error.msg.no_user": "المستخدم المرتبط بهذا الحساب {0} غير موجود.",
|
||||||
"error.msg.no_email": "Can't get email address from this {0} account.",
|
"error.msg.no_email": "لا يمكن الحصول على عنوان البريد الإلكتروني من هذا الحساب {0}.",
|
||||||
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
"error.msg.already_linked": "حساب {0} هذا مرتبط بالفعل بحساب آخر.",
|
||||||
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
"error.msg.not_linked": "لم يتم ربط حساب {0} هذا بأي حساب حتى الآن.",
|
||||||
"error.msg.unverified_account": "This {0} account is unverified, please try again after verification.",
|
"error.msg.unverified_account": "لم يتم التحقق من حساب {0} هذا، يرجى المحاولة مرة أخرى بعد التحقق.",
|
||||||
"error.msg.discord_guild_permission_denied": "You are not allowed to sign in.",
|
"error.msg.user_not_allowed": "غير مسموح لك بتسجيل الدخول.",
|
||||||
"error.msg.cannot_get_user_info": "Can not get your user info from this {0} account.",
|
"error.msg.cannot_get_user_info": "فشلت عملية جلب معلومات المستخدم الخاصة بك من حساب {0} هذا.",
|
||||||
"error.param.provider_github": "GitHub",
|
"error.param.provider_github": "GitHub",
|
||||||
"error.param.provider_google": "Google",
|
"error.param.provider_google": "Google",
|
||||||
"error.param.provider_microsoft": "Microsoft",
|
"error.param.provider_microsoft": "Microsoft",
|
||||||
"error.param.provider_discord": "Discord",
|
"error.param.provider_discord": "Discord",
|
||||||
"error.param.provider_oidc": "OpenID Connect",
|
"error.param.provider_oidc": "OpenID Connect",
|
||||||
// Common translations
|
// Common translations
|
||||||
"common.button.save": "Save",
|
"common.button.save": "حفظ",
|
||||||
"common.button.create": "Create",
|
"common.button.create": "إنشاء",
|
||||||
"common.button.submit": "Submit",
|
"common.button.submit": "إرسال",
|
||||||
"common.button.delete": "Delete",
|
"common.button.delete": "حذف",
|
||||||
"common.button.cancel": "Cancel",
|
"common.button.cancel": "إلغاء",
|
||||||
"common.button.confirm": "Confirm",
|
"common.button.confirm": "تأكيد",
|
||||||
"common.button.disable": "Disable",
|
"common.button.disable": "إيقاف",
|
||||||
"common.button.share": "Share",
|
"common.button.share": "مشاركة",
|
||||||
"common.button.generate": "Generate",
|
"common.button.generate": "توليد",
|
||||||
"common.button.done": "Done",
|
"common.button.done": "تم",
|
||||||
"common.text.link": "Link",
|
"common.text.link": "الرابط",
|
||||||
"common.text.navigate-to-link": "Go to the link",
|
"common.text.navigate-to-link": "الذهاب إلى الرابط",
|
||||||
"common.text.or": "or",
|
"common.text.or": "أو",
|
||||||
"common.button.go-back": "Go back",
|
"common.text.redirecting": "Redirecting...",
|
||||||
"common.button.go-home": "Go home",
|
"common.button.go-back": "العودة",
|
||||||
"common.notify.copied": "Your link was copied to the clipboard",
|
"common.button.go-home": "العودة للصفحة الرئيسية",
|
||||||
"common.success": "Success",
|
"common.notify.copied": "تم نسخ الرابط إلى الحافظة",
|
||||||
"common.error": "Error",
|
"common.success": "تم",
|
||||||
"common.error.unknown": "An unknown error occurred",
|
"common.error": "خطأ",
|
||||||
"common.error.invalid-email": "Invalid email address",
|
"common.error.unknown": "حدث خطأ غير معروف",
|
||||||
"common.error.too-short": "Must be at least {length} characters",
|
"common.error.invalid-email": "عنوان البريد غير صحيح",
|
||||||
"common.error.too-long": "Must be at most {length} characters",
|
"common.error.too-short": "يجب أن يكون على الأقل {length} حرفًا",
|
||||||
"common.error.exact-length": "Must be exactly {length} characters",
|
"common.error.too-long": "يجب أن يكون على الأكثر {length} حرفًا",
|
||||||
"common.error.invalid-number": "Must be a number",
|
"common.error.number-too-small": "Must be at least {min}",
|
||||||
"common.error.field-required": "This field is required"
|
"common.error.number-too-large": "Must be at most {max}",
|
||||||
|
"common.error.exact-length": "يجب أن يكون بالضبط {length} حرفًا",
|
||||||
|
"common.error.invalid-number": "يجب أن يكون رقماً",
|
||||||
|
"common.error.field-required": "هذا الحقل مطلوب"
|
||||||
};
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user