Compare commits

..

163 Commits

Author SHA1 Message Date
Elias Schneider
0a963bfaf1 release: 0.29.0 2024-07-30 08:43:30 +02:00
Elias Schneider
472c93d548 chore: save caddy logs to caddy.log 2024-07-30 08:43:11 +02:00
Elias Schneider
93aacca9b4 refactor: run formatter 2024-07-30 08:39:22 +02:00
Elias Schneider
3505669135 chore(translations): update translations via Crowdin (#540)
* New translations en-us.ts (French)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)
2024-07-30 08:27:36 +02:00
Ivan Li
fe735f9704 feat: add more options to reverse shares (#495)
* feat(reverse-share): optional simplified interface for reverse sharing. issue #155.

* chore: Remove useless form validation.

* feat: Share Ready modal adds a prompt that an email has been sent to the reverse share creator.

* fix: Simplified reverse shared interface elements lack spacing when not logged in.

* fix: Share Ready modal prompt contrast is too low in dark mode.

* feat: add public access options to reverse share.

* feat: remember reverse share simplified and publicAccess options in cookies.

* style: npm run format.

* chore(i18n): Improve translation.

Co-authored-by: Elias Schneider <login@eliasschneider.com>

Update frontend/src/i18n/translations/en-US.ts

Co-authored-by: Elias Schneider <login@eliasschneider.com>

Update frontend/src/i18n/translations/en-US.ts

Co-authored-by: Elias Schneider <login@eliasschneider.com>

chore(i18n): Improve translation.

* chore: Improved variable naming.

* chore(i18n): Improve translation. x2.

* fix(backend/shares): Misjudged the permission of the share of the reverse share.
2024-07-30 08:26:56 +02:00
Elias Schneider
3563715f57 chore(frontend): remove unused dependency 2024-07-28 16:09:31 +02:00
Elias Schneider
14c2185e6f Revert "fix: set max age of access token cookie to 15 minutes"
This reverts commit 2dac38560b.
2024-07-27 17:15:20 +02:00
Elias Schneider
27ee9fb6cb feat: sort share files by name by default 2024-07-25 19:32:00 +02:00
Elias Schneider
601772d2f4 release: 0.28.0 2024-07-22 13:36:54 +02:00
Elias Schneider
0e66be5f08 chore: resolve uncomplete merge conflict 2024-07-22 13:36:41 +02:00
Elias Schneider
4cabcfb715 chore(translations): update translations via Crowdin (#532)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Arabic, Egypt)
2024-07-22 11:01:20 +02:00
Maurice Schorn
e5e9d85d39 chore: remove obsolete version from docker compose
* compose version tag is not a necessity

* adjust default nextjs port

* Update docker-compose.yml

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-07-17 23:26:57 +02:00
Marvin A. Ruder
70fd2d94be feat(auth): Add role-based access management from OpenID Connect (#535)
* feat(auth): Add role-based access management from OpenID Connect

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

* Apply suggestions from code review

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

---------

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
2024-07-17 23:25:42 +02:00
Elias Schneider
e5a0c649e3 fix: store only 10 share tokens in the cookies and clear the expired ones 2024-07-16 19:17:53 +02:00
Anti-Apple4life
414bcecbb5 chore: fix compile-time errors and warnings in i18n translations (#531)
* Fix aingle-quote warning in fi-FI.ts

* Fix duplicate key in fr-FR.ts
2024-07-11 23:43:15 +02:00
Elias Schneider
968352cb6c release: 0.27.0 2024-07-11 21:57:37 +02:00
Elias Schneider
355f860387 chore(translations): update translations via Crowdin (#524)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (French)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Turkish)
2024-07-11 21:57:08 +02:00
thecrafterjt
083d82c28b feat(smtp): allow unauthorized mail server certificates (#525)
* Update config.seed.ts

Added Config Option "allowUnauthenticatedCertificates".

* Update email.service.ts

Now using new Config Option "allowUnauthenticatedCertificates".

* Update en-US.ts

* Update ar-EG.ts

* Update da-DK.ts

* Update el-GR.ts

* Update es-ES.ts

* Update fi-FI.ts

* Update fr-FR.ts

* Update hu-HU.ts

* Update it-IT.ts

* Update ja-JP.ts

* Update ko-KR.ts

* Update nl-BE.ts

* Update pl-PL.ts

* Update pt-BR.ts

* Update ru-RU.ts

* Update sl-SI.ts

* Update sr-SP.ts

* Update sv-SE.ts

* Update th-TH.ts

* Update tr-TR.ts

* Update uk-UA.ts

* Update zh-CN.ts

* Update zh-TW.ts

* Update config.seed.ts

* Update email.service.ts

* Update de-DE.ts

* Add files via upload

rename allow-unauthenticated-certificates to allow-unauthorized-certificates

* Add files via upload

rename allowUnauthenticatedCertificates to allowUnauthorizedCertificates

* Add files via upload

rename allowUnauthenticatedCertificates to allowUnauthorizedCertificates

* rename "unauthenticated" to "unauthorized"

* refactor: run formatter

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-07-11 21:50:09 +02:00
Elias Schneider
046c630abf Merge branches 'main' and 'main' of https://github.com/stonith404/pingvin-share 2024-07-10 18:39:53 +02:00
Elias Schneider
d2bfb9a55f feat: add logs for successful registration, successful login and failed login 2024-07-10 18:39:47 +02:00
Elias Schneider
fccf57e9e4 chore(translations): update translations via Crowdin (#520)
* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Korean)
2024-07-07 23:08:28 +02:00
Marvin A. Ruder
e1a68f75f7 feat(auth): Allow to hide username / password login form when OAuth is enabled (#518)
* 🚀 Feature: Allow to hide username / password login form when OAuth is enabled

* Hide “Sign in” password form
* Disable routes related to password authentication
* Change styling of OAuth provider buttons
* Open OAuth page in same tab
* Fix consistent usage of informal language in de-DE locale

Fixes #489

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

* fix: order of new config variables

---------

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-07-07 23:08:14 +02:00
Elias Schneider
9d9cc7b4ab release: 0.26.0 2024-07-03 08:21:14 +02:00
Elias Schneider
d1cde75a66 chore(translations): update translations via Crowdin (#516)
* New translations en-us.ts (Turkish)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Turkish)

* New translations en-us.ts (Italian)
2024-07-03 08:19:52 +02:00
Elias Schneider
bbc81d8dd0 chore(translations): add Turkish files 2024-07-02 13:38:05 +02:00
Elias Schneider
0cdc04bfb5 chore(translations): update translations via Crowdin (#515)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)
2024-07-02 13:36:47 +02:00
Marvin A. Ruder
367f804a49 feat(backend): Make session duration configurable (#512)
* feat(backend): Make session duration configurable
Fixes #507

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

* Apply suggestions from code review

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Move new config option to “General” category

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

---------

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-07-02 13:35:12 +02:00
Elias Schneider
9193a79b9a chore: upgrade dependencies 2024-07-01 11:08:23 +02:00
Marvin A. Ruder
31366d961f fix(oauth): provider username is ignored when signing up using OAuth (#511)
* 🐛 Bug Report: Provider username is ignored when signing up using OAuth
Fixes #505

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

* Implement fallback logic for username conflicts

* Reprioritize claims for OIDC provider username

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>

---------

Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
2024-07-01 10:34:31 +02:00
Elias Schneider
2dac38560b fix: set max age of access token cookie to 15 minutes 2024-06-30 20:10:16 +02:00
IRahul MIshra
db2720ab7b Worked on issue Feature Add email recipients more efficiently issue #500 (#510)
* Worked on issue #500 Feature Add email recipients more efficiently

* Worked on issue #500 Feature Add email recipients more efficiently both features added

* Removed log

* refactor: run formatter

---------

Co-authored-by: Rahul Mishra <rahul07@Rahuls-Laptop.local>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-06-30 19:53:41 +02:00
Elias Schneider
6d6b9e81ff New translations en-us.ts (French) (#504) 2024-06-26 20:48:56 +02:00
Elias Schneider
f9ddd7bacd chore(translations): update translations via Crowdin (#501)
* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Danish)
2024-06-18 08:51:22 +02:00
Elias Schneider
3773432eb5 chore(translations): update translations via Crowdin (#497)
* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Russian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Hungarian)
2024-06-11 11:51:11 +02:00
Elias Schneider
46783ce463 release: 0.25.0 2024-06-10 11:43:12 +02:00
Elias Schneider
c0cc16fa43 fix: share size not displayed on my shares page 2024-06-10 11:41:41 +02:00
Ivan Li
4fd29037a0 Feature: add auto open share modal config for global. (#474)
* feat(admin): add auto open share modal config for global.

* feat(upload): Apply the flag that disables the automatic open create share modal.

* fix: remove migration and add new config variable to seed script

* chore(translations): improve auto open share modal description

* refactor: run formatter

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-06-10 11:32:52 +02:00
Leo Li
1c7832ad1f feat(frontend): locale for dates and tooltip for copy link button (#492)
* Add tooltip for copy button

* Set locale globally for moment.js

* format

* remove debugging log

* refactor: rename translation key

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-06-10 11:01:59 +02:00
Cabeza
962ec27df4 chore: sanitize appUrl to remove trailing slash in updateConfigVariable function (#496) 2024-06-10 11:01:08 +02:00
Elias Schneider
9268e35141 chore(translations): update translations via Crowdin (#485)
* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Russian)
2024-06-10 10:48:37 +02:00
Elias Schneider
e8be0d60e6 docs: add Discord to issue page 2024-05-24 09:29:18 +02:00
Elias Schneider
0eabf78f13 chore: remove question issue template 2024-05-24 08:09:47 +02:00
Elias Schneider
4136bf5778 docs: update Discord link 2024-05-24 08:09:16 +02:00
Elias Schneider
42b3604e2a docs: add badges to README 2024-05-23 22:08:52 +02:00
Elias Schneider
84f4c39c1e release: 0.24.2 2024-05-22 15:21:07 +02:00
Elias Schneider
bfef246d98 New translations en-us.ts (French) (#475) 2024-05-22 15:20:50 +02:00
Elias Schneider
3b89fb950a chore: update dependencies 2024-05-22 15:20:33 +02:00
Elias Schneider
7afda85f03 fix: admin couldn't delete shares created by anonymous users 2024-05-17 15:13:56 +02:00
Elias Schneider
a3a7a5d9ab Merge branch 'main' of https://github.com/stonith404/pingvin-share 2024-05-17 14:42:27 +02:00
Elias Schneider
74cd520cb8 fix: whitespace in title on homepage 2024-05-17 14:42:14 +02:00
Elias Schneider
a511f24a6b chore(translations): update translations via Crowdin (#467)
* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Korean)

* New translations en-us.ts (Italian)

* New translations en-us.ts (German)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Simplified)
2024-05-14 09:49:51 +02:00
Maurice Schorn
b3862f3f3e change docker command (#470) 2024-05-14 09:49:40 +02:00
Elias Schneider
d147614f76 release: 0.24.1 2024-05-04 14:45:19 +03:00
Elias Schneider
c999df15e0 fix: error on admin share management page if a share was created by an anonymous user 2024-05-04 14:45:08 +03:00
Elias Schneider
908d6e298f release: 0.24.0 2024-05-04 10:11:19 +03:00
Elias Schneider
44c4a2e269 chore(translations): update translations via Crowdin (#465)
* New translations en-us.ts (Spanish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (German)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (French)

* New translations en-us.ts (Danish)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Korean)
2024-05-04 10:09:01 +03:00
Elias Schneider
dc060f258b chore(translations): add korean language files 2024-05-04 00:22:17 +03:00
SFGrenade
3b1c9f1efb feat: add admin-exclusive share-management page (#461)
* testing with all_shares

* share table

* share table

* change icon on admin page

* add share size to list

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-05-04 00:18:27 +03:00
Elias Schneider
a45184995f chore(translations): update translations via Crowdin (#464)
* New translations en-us.ts (German)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (French)

* New translations en-us.ts (Danish)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)
2024-05-03 17:24:27 +02:00
Elias Schneider
b717663b5c feat: add name property to share (#462)
* add name property to share

* refactor: run formatter

* tests: adapt system tests

* tests: adapt second system test
2024-05-03 17:12:26 +02:00
Elias Schneider
0e12ba87bc chore(translations): update translations via Crowdin (#453)
* New translations en-us.ts (German)

* New translations en-us.ts (German)

* New translations en-us.ts (Spanish)
2024-04-26 13:20:46 +03:00
Yuanlin Lin
ec1feadee9 doc: add Zeabur installation guide (#447)
* docs: add Zeabur installation

https://youtu.be/JOhWUvSSJYQ

* chore: remove zeabur button
2024-04-26 13:19:00 +03:00
Elias Schneider
2e0d8d4fed chore(translations): update translations via Crowdin (#440)
* New translations en-us.ts (Danish)

* New translations en-us.ts (Hungarian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Danish)

* New translations en-us.ts (French)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Greek)
2024-04-23 12:01:45 +03:00
MaYunFei
b7f0f9d3ee chore(translations): rename Simplified Chinese -> 简体中文 (#451) 2024-04-23 12:01:33 +03:00
Elias Schneider
c303454db3 release: 0.23.1 2024-04-05 13:54:55 +02:00
Elias Schneider
3972589f76 fix: normal shares were added to the previous reverse share 2024-04-05 13:54:36 +02:00
Elias Schneider
3c5e0ad513 fix: incorrect layout on 404 page 2024-04-05 12:03:38 +02:00
Elias Schneider
384fd19203 fix: redirect vulnerability on error, sign in and totp page 2024-04-05 12:00:41 +02:00
Elias Schneider
9d1a12b0d1 fix: disable js execution on raw file view 2024-04-05 11:37:47 +02:00
Elias Schneider
24e100bd7b fix: changing the chunk size needed an app restart 2024-04-05 11:31:43 +02:00
Elias Schneider
1da4feeb89 fix(backend): crash on unhandled promise rejections 2024-04-04 23:18:00 +02:00
Elias Schneider
c0a245e11b release: 0.23.0 2024-04-04 22:54:39 +02:00
Elias Schneider
7a15fbb465 fix: memory leak while uploading files by disabling base64 encoding of chunks 2024-04-04 20:55:45 +02:00
Elias Schneider
0bfbaea49a feat: add config variable to adjust chunk size 2024-04-04 20:54:21 +02:00
Elias Schneider
82871ce5dc chore(translations): update translations via Crowdin (#436)
* New translations en-us.ts (Hungarian)

* New translations en-us.ts (Ukrainian)

* New translations en-us.ts (Ukrainian)
2024-04-04 20:01:04 +02:00
Elias Schneider
593a65dac1 chore(translations): rename language code of Ukrainian to uk-UA 2024-04-04 19:58:39 +02:00
theGrove
92ee1ab527 chore(translations): add Ukrainian (#438)
* add Ukrainian lenguage

add Ukrainian lenguage

* fix: change locale key

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-04-04 19:52:03 +02:00
Elias Schneider
e71f6cd159 fix: delete share files if user gets deleted 2024-03-28 11:59:50 +01:00
Elias Schneider
0b07bfbc14 docs: update frontend start command 2024-03-28 11:48:34 +01:00
Elias Schneider
63842cd0cc chore(translations): add hungarian files 2024-03-28 11:33:43 +01:00
Elias Schneider
9f686c6ee3 chore(translations): update translations via Crowdin (#416)
* New translations en-us.ts (Arabic, Egypt)

* New translations en-us.ts (French)
2024-03-28 11:32:30 +01:00
Elias Schneider
c6d8188e4e fix: error in logs if "allow unauthenticated shares" is enabled 2024-03-25 19:12:27 +01:00
Elias Schneider
6d87e20e29 docs: add npm install to upgrade guide 2024-03-07 09:43:14 +01:00
Elias Schneider
b8efb9f54b release: 0.22.2 2024-02-29 14:43:08 +01:00
Elias Schneider
013b9886af fix: extend access token cookie expiration 2024-02-29 14:42:05 +01:00
Elias Schneider
43bff91db2 fix: replace Nginx with Caddy to fix "premature close" error while downloading larger files 2024-02-29 14:41:45 +01:00
Elias Schneider
1aa3d8e5e8 fix: reduce refresh access token calls 2024-02-27 09:40:52 +01:00
Elias Schneider
4dae7e250a docs: improve configuration section in README 2024-02-27 09:24:07 +01:00
Elias Schneider
7e91d83f9a chore(translations): add Arabic translation files 2024-02-27 09:12:46 +01:00
Elias Schneider
e11dbfe893 chore(translations): update translations via Crowdin (#411)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Polish)
2024-02-27 09:11:25 +01:00
Elias Schneider
ea83cf3876 docs: add environment variable step to stand-alone docs 2024-02-18 21:53:11 +01:00
Elias Schneider
5ca0bffc0a release: 0.22.1 2024-02-18 21:48:23 +01:00
Elias Schneider
64515d77cf fix: user enumaration on forgot password page 2024-02-18 21:46:50 +01:00
Elias Schneider
6058dca273 Merge branch 'main' of https://github.com/stonith404/pingvin-share 2024-02-18 21:32:04 +01:00
Elias Schneider
d01cba4a06 Merge branch 'fix/replace-middleware-url' 2024-02-18 21:30:52 +01:00
Elias Schneider
98aa9f97ea chore(translations): update translations via Crowdin (#399)
* New translations en-us.ts (Italian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (French)

* New translations en-us.ts (Italian)
2024-02-18 21:30:10 +01:00
Elias Schneider
9c734ec439 fix: prevent zoom on input field click on mobile 2024-02-11 16:22:19 +01:00
Elias Schneider
e663da45b1 fix: user id and totpVerified can't be changed by user 2024-02-11 16:19:19 +01:00
Elias Schneider
f52dffdaac fix: back links on error modals 2024-02-05 16:13:54 +01:00
Elias Schneider
e572506d4f refactor: run formatter 2024-02-05 16:11:49 +01:00
Elias Schneider
416eba6ae6 release: 0.22.0 2024-02-04 18:57:49 +01:00
Elias Schneider
3880854240 chore(translations): update translations via Crowdin (#385)
* New translations en-us.ts (Greek)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (French)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Greek)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Slovenian)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (French)

* New translations en-us.ts (French)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Dutch, Belgium)
2024-02-04 18:54:48 +01:00
Maurice Schorn
43d186a370 Markdown support for TextPreviews (#396)
* add markdown-to-jsx dependency

* replace TextPreview with Markdown

* basic table styling

* add light mode backgroundColor
2024-02-04 18:50:43 +01:00
Elias Schneider
76df6f66d9 fix: replace middleware backend url with local backend url 2024-01-23 15:22:08 +01:00
Elias Schneider
c189cd97a5 fix(translations): typo in string 2024-01-18 09:13:31 +01:00
Elias Schneider
d83e28a1c3 chore(translations): add Slovenian files 2024-01-14 18:53:55 +01:00
Elias Schneider
3299f767d3 release: 0.21.5 2024-01-14 14:16:47 +01:00
Elias Schneider
16a9724693 chore(translations): update translations via Crowdin (#378)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (German)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Greek)
2024-01-14 14:15:44 +01:00
Elias Schneider
0ccb836444 fix: password can be changed with wrong password 2024-01-14 14:14:07 +01:00
Elias Schneider
067652aa80 chore(translations): add Greek files 2024-01-14 13:19:51 +01:00
Elias Schneider
1523d1b5b2 release: 0.21.4 2024-01-09 21:29:29 +01:00
Elias Schneider
ea14e28dd8 chore(translations): update translations via Crowdin (#370)
* New translations en-us.ts (Italian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (German)

* New translations en-us.ts (German)
2024-01-09 15:51:04 +01:00
Ivan Li
d7750086b5 feat(frontend): add navigateToLink button for CopyTextField. close #372. (#376)
* feat(frontend): add navigateToLink button for CopyTextField. close #372.

* chore(frontend): remove unused props for CopyTextField.
2024-01-09 15:50:42 +01:00
Ivan Li
eb7216b4b1 chore(frontend/share): displays the never expire checkbox if the system allows. (#371) 2024-01-07 22:13:59 +01:00
Elias Schneider
1d62225019 Merge branch 'main' of https://github.com/stonith404/pingvin-share 2024-01-04 15:46:43 +01:00
Elias Schneider
bf5250c4a7 ci/cd: remove close inactive issue action 2024-01-04 15:46:40 +01:00
Elias Schneider
cdd0a864d1 chore(translations): update translations via Crowdin (#365)
* New translations en-us.ts (Italian)

* New translations en-us.ts (Danish)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Italian)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Dutch, Belgium)
2024-01-03 13:48:42 +01:00
Elias Schneider
692c1bef25 release: 0.21.3 2024-01-02 21:40:49 +01:00
Elias Schneider
fe09d0e25f fix: don't show validation error on upload modal if password or max views are empty 2024-01-02 21:33:15 +01:00
Elias Schneider
3ce18dc1dc release: 0.21.2 2023-12-29 18:15:42 +01:00
Elias Schneider
6fb31abd84 fix: missing logo images on fresh installation 2023-12-29 18:12:02 +01:00
Elias Schneider
7a301b455c fix: missing translations on reset password page 2023-12-29 18:09:31 +01:00
Elias Schneider
5781a7b540 chore(translations): add Italian files 2023-12-27 13:54:37 +01:00
Elias Schneider
2efbeee5bf chore(translations): update translations via Crowdin (#359)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (French)

* New translations en-us.ts (Polish)
2023-12-27 13:45:07 +01:00
Elias Schneider
be4ff0f0f0 release: 0.21.1 2023-12-20 12:33:36 +01:00
Qing Fu
3ea52a24ef feat(oauth): add oidc username claim (#357)
* feat(oauth): add oidc username claim

* style: remove undefined
2023-12-20 12:32:42 +01:00
No Solo Hacking
f179189b59 docs: add review by "No Solo Hacking" to the Spanish README (#356)
* Update README.es.md

* Update docs/README.es.md

Co-authored-by: Elias Schneider <login@eliasschneider.com>

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-12-15 07:27:01 +01:00
Elias Schneider
bc333f768f chore(translations): update translations via Crowdin (#349)
* New translations en-us.ts (Swedish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (French)
2023-12-13 08:41:42 +01:00
Elias Schneider
26c98e2b41 chore: fix deps vulnerabilities 2023-12-01 11:03:03 +01:00
Elias Schneider
4b7732838d release: 0.21.0 2023-12-01 10:28:09 +01:00
Elias Schneider
021b9ac5d5 chore(translations): update translations via Crowdin (#347)
* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (German)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Chinese Traditional)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Polish)
2023-12-01 10:27:40 +01:00
Qing Fu
5f94c7295a feat(oauth): limited discord server sign-in (#346)
* feat(oauth): limited discord server sign-in

* fix: typo

* style: change undefined to optional

* style: remove conditional operator
2023-11-30 22:41:06 +01:00
Rhys Chang
d9a9523c9a New translations zh-TW.ts (#339) 2023-11-26 12:49:08 +01:00
Elias Schneider
384d2343d5 New translations en-us.ts (Portuguese, Brazilian) (#336) 2023-11-26 12:48:22 +01:00
Elias Schneider
7a387d86d6 release: 0.20.3 2023-11-17 15:27:31 +01:00
Elias Schneider
330eef51e4 fix: max expiration gets ignored if expiration is set to "never" 2023-11-17 15:27:22 +01:00
Elias Schneider
2e1a2b60c4 release: 0.20.2 2023-11-11 20:29:24 +01:00
Elias Schneider
9896ca0e8c chore(translations): update translations via Crowdin (#313)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Swedish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Russian)
2023-11-11 20:27:03 +01:00
Qing Fu
fd44f42f28 fix(oauth): github and discord login error (#323)
* fix(oauth): github and discord login error
fixed #322, fixed #302

* feat(oauth): print log when ErrorPageException occurs

* refactor(oauth): migrate to Logger

* feat(oauth): add logger for OAuthExceptionFilter

* docs(oauth): update oauth login docs
2023-11-11 20:25:05 +01:00
Elias Schneider
966ce261cb fix: reverse shares couldn't be created unauthenticated 2023-11-11 18:57:54 +01:00
Elias Schneider
5503e7a54f chore(translations): add Swedish translation files 2023-11-08 08:18:10 +01:00
Elias Schneider
b49ec93c54 release: 0.20.1 2023-11-05 12:38:13 +01:00
Elias Schneider
e6584322fa chore(translations): update translations via Crowdin (#310)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (German)
2023-11-05 12:37:48 +01:00
Elias Schneider
1138cd02b0 fix: share information text color in light mode 2023-11-05 12:36:42 +01:00
Elias Schneider
1ba8d0cbd1 release: 0.20.0 2023-11-04 20:40:20 +01:00
Ivan Li
98380e2d48 feat: ability to add and delete files of existing share (#306)
* feat(share): delete file api, revert complete share api.

* feat(share): share edit page.

* feat(share): Modify the DropZone title of the edit sharing UI.

* feat(share): i18n for edit share. (en, zh)

* feat(share): allow creator get share by id.

* feat(share): add edit button in account/shares.

* style(share): lint.

* chore: some minor adjustments.

* refactor: run formatter

* refactor: remove unused return

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-11-04 20:39:58 +01:00
Elias Schneider
e377ed10e1 release: 0.19.2 2023-11-03 14:07:25 +01:00
Elias Schneider
acc35f4717 fix: wrong validation of setting max share expiration to 0 2023-11-03 14:05:43 +01:00
Elias Schneider
33742a043d fix: jwt secret changes on application restart 2023-11-03 13:06:59 +01:00
Elias Schneider
5cee9cbbb9 chore(translations): update translations via Crowdin (#298)
* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Danish)

* New translations en-us.ts (French)

* New translations en-us.ts (French)

* New translations en-us.ts (French)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)
2023-11-03 10:39:13 +01:00
Elias Schneider
e0fbbeca3c feat: change totp issuer to display logo in 2FAS app 2023-11-03 08:38:23 +01:00
Elias Schneider
bbfc9d6f14 feat: ability to limit the max expiration of a share 2023-10-23 15:17:47 +02:00
Elias Schneider
46b6e56c06 release: 0.19.1 2023-10-22 21:21:37 +02:00
Elias Schneider
05f6582739 chore(translations): update translations via Crowdin (#295)
* New translations en-us.ts (German)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Portuguese, Brazilian)
2023-10-22 21:21:17 +02:00
Qing Fu
119b1ec840 fix(oauth): fix wrong redirectUri in oidc after change appUrl (#296) 2023-10-22 21:20:50 +02:00
Elias Schneider
e89e313712 release: 0.19.0 2023-10-22 16:15:25 +02:00
Elias Schneider
c2ff658182 chore(translations): update translations via Crowdin (#294)
* New translations en-us.ts (Polish)

* New translations en-us.ts (French)

* New translations en-us.ts (Spanish)

* New translations en-us.ts (Danish)

* New translations en-us.ts (German)

* New translations en-us.ts (Finnish)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Russian)

* New translations en-us.ts (Serbian (Cyrillic))

* New translations en-us.ts (Chinese Simplified)

* New translations en-us.ts (Portuguese, Brazilian)

* New translations en-us.ts (Thai)

* New translations en-us.ts (Dutch, Belgium)

* New translations en-us.ts (Polish)
2023-10-22 16:13:35 +02:00
Qing Fu
02cd98fa9c feat(auth): add OAuth2 login (#276)
* feat(auth): add OAuth2 login with GitHub and Google

* chore(translations): add files for Japanese

* fix(auth): fix link function for GitHub

* feat(oauth): basic oidc implementation

* feat(oauth): oauth guard

* fix: disable image optimizations for logo to prevent caching issues with custom logos

* fix: memory leak while downloading large files

* chore(translations): update translations via Crowdin (#278)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)

* release: 0.18.2

* doc(translations): Add Japanese README (#279)

* Added Japanese README.

* Added JAPANESE README link to README.md.

* Updated Japanese README.

* Updated Environment Variable Table.

* updated zh-cn README.

* feat(oauth): unlink account

* refactor(oauth): make providers extensible

* fix(oauth): fix discoveryUri error when toggle google-enabled

* feat(oauth): add microsoft and discord as oauth provider

* docs(oauth): update README.md

* docs(oauth): update oauth2-guide.md

* set password to null for new oauth users

* New translations en-us.ts (Japanese) (#281)

* chore(translations): add Polish files

* fix(oauth): fix random username and password

* feat(oauth): add totp

* fix(oauth): fix totp throttle

* fix(oauth): fix qrcode and remove comment

* feat(oauth): add error page

* fix(oauth): i18n of error page

* feat(auth): add OAuth2 login

* fix(auth): fix link function for GitHub

* feat(oauth): basic oidc implementation

* feat(oauth): oauth guard

* feat(oauth): unlink account

* refactor(oauth): make providers extensible

* fix(oauth): fix discoveryUri error when toggle google-enabled

* feat(oauth): add microsoft and discord as oauth provider

* docs(oauth): update README.md

* docs(oauth): update oauth2-guide.md

* set password to null for new oauth users

* fix(oauth): fix random username and password

* feat(oauth): add totp

* fix(oauth): fix totp throttle

* fix(oauth): fix qrcode and remove comment

* feat(oauth): add error page

* fix(oauth): i18n of error page

* refactor: return null instead of `false` in `getIdOfCurrentUser` functiom

* feat: show original oauth error if available

* refactor: run formatter

* refactor(oauth): error message i18n

* refactor(oauth): make OAuth token available
someone may use it (to revoke token or get other info etc.)
also improved the i18n message

* chore(oauth): remove unused import

* chore: add database migration

* fix: missing python installation for nanoid

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: ふうせん <10260662+fusengum@users.noreply.github.com>
2023-10-22 16:09:53 +02:00
Elias Schneider
d327bc355c fix: delete unfinished shares after a day 2023-10-21 18:51:27 +02:00
Elias Schneider
8ae631a626 chore(translations): update translations via Crowdin (#284)
* New translations en-us.ts (Polish)

* New translations en-us.ts (German)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)

* New translations en-us.ts (Polish)
2023-10-21 18:36:30 +02:00
Elias Schneider
1d8dc8fe5b chore(translations): add Polish files 2023-10-12 14:30:04 +02:00
Elias Schneider
688ae6c86e New translations en-us.ts (Japanese) (#281) 2023-10-12 14:28:03 +02:00
ふうせん
21809843cd doc(translations): Add Japanese README (#279)
* Added Japanese README.

* Added JAPANESE README link to README.md.

* Updated Japanese README.

* Updated Environment Variable Table.

* updated zh-cn README.
2023-10-10 08:19:28 +02:00
149 changed files with 19288 additions and 11817 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View 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

View File

@@ -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.

View File

@@ -1,23 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "00 00 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v4
with:
days-before-issue-stale: 30
days-before-issue-close: 14
exempt-issue-labels: "feature"
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,3 +1,244 @@
## [0.29.0](https://github.com/stonith404/pingvin-share/compare/v0.28.0...v0.29.0) (2024-07-30)
### Features
* add more options to reverse shares ([#495](https://github.com/stonith404/pingvin-share/issues/495)) ([fe735f9](https://github.com/stonith404/pingvin-share/commit/fe735f9704c9d96398f3127a559e17848b08d140)), closes [#155](https://github.com/stonith404/pingvin-share/issues/155)
* sort share files by name by default ([27ee9fb](https://github.com/stonith404/pingvin-share/commit/27ee9fb6cb98177661bed20a0baa399b27e70b7e))
### Reverts
* Revert "fix: set max age of access token cookie to 15 minutes" ([14c2185](https://github.com/stonith404/pingvin-share/commit/14c2185e6f1a81d63e25fbeec3e30a54cf6a44c5))
## [0.28.0](https://github.com/stonith404/pingvin-share/compare/v0.27.0...v0.28.0) (2024-07-22)
### Features
* **auth:** Add role-based access management from OpenID Connect ([#535](https://github.com/stonith404/pingvin-share/issues/535)) ([70fd2d9](https://github.com/stonith404/pingvin-share/commit/70fd2d94be3411cc430f5c56e522028398127efb))
### Bug Fixes
* store only 10 share tokens in the cookies and clear the expired ones ([e5a0c64](https://github.com/stonith404/pingvin-share/commit/e5a0c649e36e0db419d04446affe2564c45cf321))
## [0.27.0](https://github.com/stonith404/pingvin-share/compare/v0.26.0...v0.27.0) (2024-07-11)
### Features
* add logs for successful registration, successful login and failed login ([d2bfb9a](https://github.com/stonith404/pingvin-share/commit/d2bfb9a55fdad6a05377b8552471cf1151304c90))
* **auth:** Allow to hide username / password login form when OAuth is enabled ([#518](https://github.com/stonith404/pingvin-share/issues/518)) ([e1a68f7](https://github.com/stonith404/pingvin-share/commit/e1a68f75f7b034f1ef9e45f26de584f13e355589)), closes [#489](https://github.com/stonith404/pingvin-share/issues/489)
* **smtp:** allow unauthorized mail server certificates ([#525](https://github.com/stonith404/pingvin-share/issues/525)) ([083d82c](https://github.com/stonith404/pingvin-share/commit/083d82c28b835c178f076e89ef8f5885e8ea31cb))
## [0.26.0](https://github.com/stonith404/pingvin-share/compare/v0.25.0...v0.26.0) (2024-07-03)
### Features
* **backend:** Make session duration configurable ([#512](https://github.com/stonith404/pingvin-share/issues/512)) ([367f804](https://github.com/stonith404/pingvin-share/commit/367f804a494c85b4caf879d51982339fb6b86ba1)), closes [#507](https://github.com/stonith404/pingvin-share/issues/507)
### Bug Fixes
* **oauth:** provider username is ignored when signing up using OAuth ([#511](https://github.com/stonith404/pingvin-share/issues/511)) ([31366d9](https://github.com/stonith404/pingvin-share/commit/31366d961f5827c200038b65ec9de5d4ddc8b898)), closes [#505](https://github.com/stonith404/pingvin-share/issues/505)
* set max age of access token cookie to 15 minutes ([2dac385](https://github.com/stonith404/pingvin-share/commit/2dac38560b6c54b6e7676dcd4682bfa57973292f))
## [0.25.0](https://github.com/stonith404/pingvin-share/compare/v0.24.2...v0.25.0) (2024-06-10)
### Features
* add auto open share modal config for global. ([#474](https://github.com/stonith404/pingvin-share/issues/474)) ([4fd2903](https://github.com/stonith404/pingvin-share/commit/4fd29037a08dbe505bdd8cf20f6f114cbade8483))
* **frontend:** locale for dates and tooltip for copy link button ([#492](https://github.com/stonith404/pingvin-share/issues/492)) ([1c7832a](https://github.com/stonith404/pingvin-share/commit/1c7832ad1fb445fd1dbe1c111be5a331eaa4b797))
### Bug Fixes
* share size not displayed on my shares page ([c0cc16f](https://github.com/stonith404/pingvin-share/commit/c0cc16fa430bc64afb024c19d5faf24456bd417c))
## [0.24.2](https://github.com/stonith404/pingvin-share/compare/v0.24.1...v0.24.2) (2024-05-22)
### Bug Fixes
* admin couldn't delete shares created by anonymous users ([7afda85](https://github.com/stonith404/pingvin-share/commit/7afda85f03d410a6c611860d0c3fb2b88a2e3679))
* whitespace in title on homepage ([74cd520](https://github.com/stonith404/pingvin-share/commit/74cd520cb8c4ea87822ab6d54c0bf010455f401b))
## [0.24.1](https://github.com/stonith404/pingvin-share/compare/v0.24.0...v0.24.1) (2024-05-04)
### Bug Fixes
* error on admin share management page if a share was created by an anonymous user ([c999df1](https://github.com/stonith404/pingvin-share/commit/c999df15e04a927f6e952db3c807b9591fb14894))
## [0.24.0](https://github.com/stonith404/pingvin-share/compare/v0.23.1...v0.24.0) (2024-05-04)
### Features
* add admin-exclusive share-management page ([#461](https://github.com/stonith404/pingvin-share/issues/461)) ([3b1c9f1](https://github.com/stonith404/pingvin-share/commit/3b1c9f1efb7d02469e92537a2d1378b6cb412878))
* add name property to share ([#462](https://github.com/stonith404/pingvin-share/issues/462)) ([b717663](https://github.com/stonith404/pingvin-share/commit/b717663b5c3a4a98e361e7e39b680f4852537c59))
## [0.23.1](https://github.com/stonith404/pingvin-share/compare/v0.23.0...v0.23.1) (2024-04-05)
### Bug Fixes
* **backend:** crash on unhandled promise rejections ([1da4fee](https://github.com/stonith404/pingvin-share/commit/1da4feeb895a13d0a0ae754bd716a84e8186d081))
* changing the chunk size needed an app restart ([24e100b](https://github.com/stonith404/pingvin-share/commit/24e100bd7be8bf20778bdf2767aa35cae8d7e502))
* disable js execution on raw file view ([9d1a12b](https://github.com/stonith404/pingvin-share/commit/9d1a12b0d1812214f1fe6fa56e3848091ce4945c))
* incorrect layout on 404 page ([3c5e0ad](https://github.com/stonith404/pingvin-share/commit/3c5e0ad5134ee2d405ac420152b5825102f65bfc))
* normal shares were added to the previous reverse share ([3972589](https://github.com/stonith404/pingvin-share/commit/3972589f76519b03074d916fb2460c795b1f0737))
* redirect vulnerability on error, sign in and totp page ([384fd19](https://github.com/stonith404/pingvin-share/commit/384fd19203b63eeb4b952f83a9e1eaab1b19b90d))
## [0.23.0](https://github.com/stonith404/pingvin-share/compare/v0.22.2...v0.23.0) (2024-04-04)
### Features
* add config variable to adjust chunk size ([0bfbaea](https://github.com/stonith404/pingvin-share/commit/0bfbaea49aad0c695fee6558c89c661687912e4f))
### Bug Fixes
* delete share files if user gets deleted ([e71f6cd](https://github.com/stonith404/pingvin-share/commit/e71f6cd1598ed87366074398042a6b88675587ca))
* error in logs if "allow unauthenticated shares" is enabled ([c6d8188](https://github.com/stonith404/pingvin-share/commit/c6d8188e4e33ba682551a3ca79205ff5a6d7ead5))
* memory leak while uploading files by disabling base64 encoding of chunks ([7a15fbb](https://github.com/stonith404/pingvin-share/commit/7a15fbb4651c2fee32fb4c1ee2c9d7f12323feb0))
## [0.22.2](https://github.com/stonith404/pingvin-share/compare/v0.22.1...v0.22.2) (2024-02-29)
### Bug Fixes
* extend access token cookie expiration ([013b988](https://github.com/stonith404/pingvin-share/commit/013b9886af5629b2ead6000b962267afc761c612))
* reduce refresh access token calls ([1aa3d8e](https://github.com/stonith404/pingvin-share/commit/1aa3d8e5e89b3696cc9554f41e9ce13806dde406))
* replace Nginx with Caddy to fix "premature close" error while downloading larger files ([43bff91](https://github.com/stonith404/pingvin-share/commit/43bff91db2ba4ec68d76e601f7bc42cb7a506bc5))
## [0.22.1](https://github.com/stonith404/pingvin-share/compare/v0.22.0...v0.22.1) (2024-02-18)
### Bug Fixes
* back links on error modals ([f52dffd](https://github.com/stonith404/pingvin-share/commit/f52dffdaac5a893804525913943f3f4f99b7c55a))
* prevent zoom on input field click on mobile ([9c734ec](https://github.com/stonith404/pingvin-share/commit/9c734ec439aeaeebe172caa41bf531e6d8b3fac3))
* replace middleware backend url with local backend url ([76df6f6](https://github.com/stonith404/pingvin-share/commit/76df6f66d965dd751146468abfafb0c6acd46310))
* user `id` and `totpVerified` can't be changed by user ([e663da4](https://github.com/stonith404/pingvin-share/commit/e663da45b1d15f5e6e33118e6a28e1504688034c))
* user enumaration on forgot password page ([64515d7](https://github.com/stonith404/pingvin-share/commit/64515d77cfc116a243d78610395ccc383ba62940))
## [0.22.0](https://github.com/stonith404/pingvin-share/compare/v0.21.5...v0.22.0) (2024-02-04)
### Bug Fixes
* **translations:** typo in string ([c189cd9](https://github.com/stonith404/pingvin-share/commit/c189cd97a502cee8ea79e5187d9288d636d4983c))
## [0.21.5](https://github.com/stonith404/pingvin-share/compare/v0.21.4...v0.21.5) (2024-01-14)
### Bug Fixes
* password can be changed with wrong password ([0ccb836](https://github.com/stonith404/pingvin-share/commit/0ccb8364448d27ea07c8b11972ff454d610893c6))
## [0.21.4](https://github.com/stonith404/pingvin-share/compare/v0.21.3...v0.21.4) (2024-01-09)
### Features
* **frontend:** add navigateToLink button for CopyTextField. close [#372](https://github.com/stonith404/pingvin-share/issues/372). ([#376](https://github.com/stonith404/pingvin-share/issues/376)) ([d775008](https://github.com/stonith404/pingvin-share/commit/d7750086b5b796cfc70d8dc0c7d0ab4bd1996ca0))
## [0.21.3](https://github.com/stonith404/pingvin-share/compare/v0.21.2...v0.21.3) (2024-01-02)
### Bug Fixes
* don't show validation error on upload modal if password or max views are empty ([fe09d0e](https://github.com/stonith404/pingvin-share/commit/fe09d0e25f6fbfc4e1c9302054d3387fe8b1f0ea))
## [0.21.2](https://github.com/stonith404/pingvin-share/compare/v0.21.1...v0.21.2) (2023-12-29)
### Bug Fixes
* missing logo images on fresh installation ([6fb31ab](https://github.com/stonith404/pingvin-share/commit/6fb31abd84b22cd464b6b45bf7ca6f83853e8720))
* missing translations on reset password page ([7a301b4](https://github.com/stonith404/pingvin-share/commit/7a301b455cdea4b1dbc04cc6223e094fee9aca7b))
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
### Features
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
### Features
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
### Bug Fixes
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
### Bug Fixes
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
### Bug Fixes
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
### Features
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
### Features
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
### Bug Fixes
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
### Bug Fixes
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
### Features
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
### Bug Fixes
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
: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}
}
}

View File

@@ -13,6 +13,7 @@ RUN npm run build
# Stage 3: Backend dependencies
FROM node:20-alpine AS backend-dependencies
RUN apk add --no-cache python3
WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
@@ -29,12 +30,12 @@ RUN npm run build && npm prune --production
FROM node:20-alpine AS runner
ENV NODE_ENV=docker
# Alpine specific dependencies
RUN apk update --no-cache
RUN apk upgrade --no-cache
RUN apk add --no-cache curl nginx
# Install Caddy
RUN apk update --no-cache \
&& apk upgrade --no-cache \
&& apk add --no-cache curl caddy
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./Caddyfile /etc/caddy/Caddyfile
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public
@@ -52,9 +53,11 @@ WORKDIR /opt/app
EXPOSE 3000
# Add a health check to ensure the container is healthy
# Health check remains unchanged
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
# Application startup
# HOSTNAME=0.0.0.0 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed
CMD cp -rn /tmp/img /opt/app/frontend/public && nginx && PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
# Application startup updated for Caddy
CMD cp -rn /tmp/img/* /opt/app/frontend/public/img && \
caddy run --config /etc/caddy/Caddyfile 2> caddy.log & \
PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & \
cd backend && npm run prod

View File

@@ -1,8 +1,8 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
[![](https://dcbadge.limes.pink/api/server/wHRQ9nFRcK)](https://discord.gg/wHRQ9nFRcK) [![](https://img.shields.io/badge/Crowdin-2E3340.svg?style=for-the-badge&logo=Crowdin&logoColor=white)](https://crowdin.com/project/pingvin-share) [![](https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)](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)_
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [简体中文](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
---
@@ -31,7 +31,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
### Installation with Docker (recommended)
1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d`
2. Run `docker compose up -d`
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
@@ -60,10 +60,11 @@ 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
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 Nginx can be found in `/nginx/nginx.conf`.
**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 🐧!
@@ -79,9 +80,14 @@ 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).
#### 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/)
- [Zeabur installation](https://zeabur.com/templates/19G6OK)
### Upgrade to a new version
@@ -110,18 +116,21 @@ docker compose up -d
# 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 by going to the configuration page in your admin dashboard.
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
#### Environment variables

10750
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-backend",
"version": "0.18.2",
"version": "0.29.0",
"scripts": {
"build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch",
@@ -13,68 +13,73 @@
"seed": "ts-node prisma/seed/config.seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.1.2",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.1.2",
"@nestjs/jwt": "^10.1.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.1.2",
"@nestjs/schedule": "^3.0.1",
"@nestjs/swagger": "^7.1.4",
"@nestjs/throttler": "^4.2.1",
"@prisma/client": "^5.0.0",
"archiver": "^5.3.1",
"argon2": "^0.30.3",
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.3.9",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.3.9",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.9",
"@nestjs/schedule": "^4.0.2",
"@nestjs/swagger": "^7.3.1",
"@nestjs/throttler": "^5.2.0",
"@prisma/client": "^5.16.1",
"@types/jmespath": "^0.15.2",
"archiver": "^7.0.1",
"argon2": "^0.40.3",
"body-parser": "^1.20.2",
"clamscan": "^2.1.2",
"cache-manager": "^5.6.1",
"clamscan": "^2.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"class-validator": "^0.14.1",
"content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"jmespath": "^0.16.0",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"nodemailer": "^6.9.4",
"moment": "^2.30.1",
"nanoid": "^3.3.7",
"nodemailer": "^6.9.14",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1",
"reflect-metadata": "^0.2.2",
"rimraf": "^5.0.7",
"rxjs": "^7.8.1",
"sharp": "^0.32.4",
"ts-node": "^10.9.1"
"sharp": "^0.33.4",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@nestjs/cli": "^10.1.10",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.1.2",
"@types/archiver": "^5.3.2",
"@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3",
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.9",
"@types/archiver": "^6.0.2",
"@types/clamscan": "^2.0.8",
"@types/cookie-parser": "^1.4.7",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.17",
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.5",
"@types/nodemailer": "^6.4.9",
"@types/passport-jwt": "^3.0.9",
"@types/qrcode-svg": "^1.1.1",
"@types/express": "^4.17.21",
"@types/mime-types": "^2.1.4",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.9",
"@types/nodemailer": "^6.4.15",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode-svg": "^1.1.4",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^6.2.0",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
"cross-env": "^7.0.3",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.9.0",
"eslint-plugin-prettier": "^5.0.0",
"newman": "^5.3.2",
"prettier": "^3.0.0",
"prisma": "^5.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"newman": "^6.1.3",
"prettier": "^3.3.2",
"prisma": "^5.16.1",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.4",
"ts-loader": "^9.5.1",
"tsconfig-paths": "4.2.0",
"typescript": "^5.1.6",
"wait-on": "^7.0.1"
"typescript": "^5.5.2",
"wait-on": "^7.2.0"
}
}

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "OAuthUser" (
"id" TEXT NOT NULL PRIMARY KEY,
"provider" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"providerUsername" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
"totpSecret" TEXT
);
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Share" ADD COLUMN "name" TEXT;

View File

@@ -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;

View File

@@ -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;

View File

@@ -14,7 +14,7 @@ model User {
username String @unique
email String @unique
password String
password String?
isAdmin Boolean @default(false)
shares Share[]
@@ -26,6 +26,8 @@ model User {
totpVerified Boolean @default(false)
totpSecret String?
resetPasswordToken ResetPasswordToken?
oAuthUsers OAuthUser[]
}
model RefreshToken {
@@ -60,10 +62,20 @@ model ResetPasswordToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model OAuthUser {
id String @id @default(uuid())
provider String
providerUserId String
providerUsername String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Share {
id String @id @default(uuid())
createdAt DateTime @default(now())
name String?
uploadLocked Boolean @default(false)
isZipReady Boolean @default(false)
views Int @default(0)
@@ -91,6 +103,8 @@ model ReverseShare {
maxShareSize String
sendEmailNotification Boolean
remainingUses Int
simplified Boolean @default(false)
publicAccess Boolean @default(true)
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
@@ -134,7 +148,7 @@ model Config {
name String
category String
type String
defaultValue String @default("")
defaultValue String @default("")
value String?
obscured Boolean @default(false)
secret Boolean @default(true)

View File

@@ -5,7 +5,7 @@ const configVariables: ConfigVariables = {
internal: {
jwtSecret: {
type: "string",
defaultValue: crypto.randomBytes(256).toString("base64"),
value: crypto.randomBytes(256).toString("base64"),
locked: true,
},
},
@@ -25,6 +25,11 @@ const configVariables: ConfigVariables = {
defaultValue: "true",
secret: false,
},
sessionDuration: {
type: "number",
defaultValue: "2160",
secret: false,
},
},
share: {
allowRegistration: {
@@ -37,6 +42,11 @@ const configVariables: ConfigVariables = {
defaultValue: "false",
secret: false,
},
maxExpiration: {
type: "number",
defaultValue: "0",
secret: false,
},
maxSize: {
type: "number",
defaultValue: "1000000000",
@@ -46,12 +56,21 @@ const configVariables: ConfigVariables = {
type: "number",
defaultValue: "9",
},
chunkSize: {
type: "number",
defaultValue: "10000000",
secret: false,
},
autoOpenShareModal: {
type: "boolean",
defaultValue: "false",
secret: false,
},
},
email: {
enableShareEmailRecipients: {
type: "boolean",
defaultValue: "false",
secret: false,
},
shareRecipientsSubject: {
@@ -97,6 +116,12 @@ const configVariables: ConfigVariables = {
defaultValue: "false",
secret: false,
},
allowUnauthorizedCertificates: {
type: "boolean",
defaultValue: "false",
secret: false,
},
host: {
type: "string",
defaultValue: "",
@@ -119,6 +144,114 @@ const configVariables: ConfigVariables = {
obscured: true,
},
},
oauth: {
"allowRegistration": {
type: "boolean",
defaultValue: "true",
},
"ignoreTotp": {
type: "boolean",
defaultValue: "true",
},
"disablePassword": {
type: "boolean",
defaultValue: "false",
secret: false,
},
"github-enabled": {
type: "boolean",
defaultValue: "false",
},
"github-clientId": {
type: "string",
defaultValue: "",
},
"github-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
"google-enabled": {
type: "boolean",
defaultValue: "false",
},
"google-clientId": {
type: "string",
defaultValue: "",
},
"google-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
"microsoft-enabled": {
type: "boolean",
defaultValue: "false",
},
"microsoft-tenant": {
type: "string",
defaultValue: "common",
},
"microsoft-clientId": {
type: "string",
defaultValue: "",
},
"microsoft-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
"discord-enabled": {
type: "boolean",
defaultValue: "false",
},
"discord-limitedGuild": {
type: "string",
defaultValue: "",
},
"discord-clientId": {
type: "string",
defaultValue: "",
},
"discord-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
"oidc-enabled": {
type: "boolean",
defaultValue: "false",
},
"oidc-discoveryUri": {
type: "string",
defaultValue: "",
},
"oidc-usernameClaim": {
type: "string",
defaultValue: "",
},
"oidc-rolePath": {
type: "string",
defaultValue: "",
},
"oidc-roleGeneralAccess": {
type: "string",
defaultValue: "",
},
"oidc-roleAdminAccess": {
type: "string",
defaultValue: "",
},
"oidc-clientId": {
type: "string",
defaultValue: "",
},
"oidc-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
},
};
type ConfigVariables = {
@@ -170,12 +303,15 @@ async function seedConfigVariables() {
async function migrateConfigVariables() {
const existingConfigVariables = await prisma.config.findMany();
const orderMap: { [category: string]: number } = {};
for (const existingConfigVariable of existingConfigVariables) {
const configVariable =
configVariables[existingConfigVariable.category]?.[
existingConfigVariable.name
];
// Delete the config variable if it doesn't exist in the seed
if (!configVariable) {
await prisma.config.delete({
where: {
@@ -186,15 +322,11 @@ async function migrateConfigVariables() {
},
});
// Update the config variable if the metadata changed
} else if (
JSON.stringify({
...configVariable,
name: existingConfigVariable.name,
category: existingConfigVariable.category,
value: existingConfigVariable.value,
}) != JSON.stringify(existingConfigVariable)
) {
// Update the config variable if it exists in the seed
} else {
const variableOrder = Object.keys(
configVariables[existingConfigVariable.category]
).indexOf(existingConfigVariable.name);
await prisma.config.update({
where: {
name_category: {
@@ -207,8 +339,10 @@ async function migrateConfigVariables() {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
value: existingConfigVariable.value,
order: variableOrder,
},
});
orderMap[existingConfigVariable.category] = variableOrder + 1;
}
}
}

View File

@@ -3,18 +3,20 @@ import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module";
import { CacheModule } from "@nestjs/cache-manager";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { AppController } from "./app.controller";
import { ClamScanModule } from "./clamscan/clamscan.module";
import { ConfigModule } from "./config/config.module";
import { EmailModule } from "./email/email.module";
import { FileModule } from "./file/file.module";
import { JobsModule } from "./jobs/jobs.module";
import { OAuthModule } from "./oauth/oauth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
import { ShareModule } from "./share/share.module";
import { UserModule } from "./user/user.module";
import { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
import { AppController } from "./app.controller";
@Module({
imports: [
@@ -26,17 +28,21 @@ import { AppController } from "./app.controller";
ConfigModule,
JobsModule,
UserModule,
ThrottlerModule.forRoot({
ttl: 60,
limit: 100,
}),
ThrottlerModule.forRoot([
{
ttl: 60,
limit: 100,
},
]),
ScheduleModule.forRoot(),
ClamScanModule,
ReverseShareModule,
OAuthModule,
CacheModule.register({
isGlobal: true,
}),
],
controllers:[
AppController,
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,

View File

@@ -37,17 +37,23 @@ export class AuthController {
) {}
@Post("signUp")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
async signUp(
@Body() dto: AuthRegisterDTO,
@Req() { ip }: Request,
@Res({ passthrough: true }) response: Response,
) {
if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed");
const result = await this.authService.signUp(dto);
const result = await this.authService.signUp(dto, ip);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -57,16 +63,22 @@ export class AuthController {
}
@Post("signIn")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(200)
async signIn(
@Body() dto: AuthSignInDTO,
@Req() { ip }: Request,
@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) {
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -77,7 +89,12 @@ export class AuthController {
}
@Post("signIn/totp")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(200)
async signInTotp(
@Body() dto: AuthSignInTotpDTO,
@@ -85,7 +102,7 @@ export class AuthController {
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -95,14 +112,24 @@ export class AuthController {
}
@Post("resetPassword/:email")
@Throttle(5, 5 * 60)
@HttpCode(204)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(202)
async requestResetPassword(@Param("email") email: string) {
return await this.authService.requestResetPassword(email);
this.authService.requestResetPassword(email);
}
@Post("resetPassword")
@Throttle(5, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(204)
async resetPassword(@Body() dto: ResetPasswordDTO) {
return await this.authService.resetPassword(dto.token, dto.password);
@@ -117,11 +144,11 @@ export class AuthController {
) {
const result = await this.authService.updatePassword(
user,
dto.oldPassword,
dto.password,
dto.oldPassword,
);
response = this.addTokensToResponse(response, result.refreshToken);
this.authService.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result);
}
@@ -136,7 +163,7 @@ export class AuthController {
const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token,
);
response = this.addTokensToResponse(response, undefined, accessToken);
this.authService.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken });
}
@@ -172,22 +199,4 @@ export class AuthController {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code);
}
private addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string,
) {
if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
return response;
}
}

View File

@@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({
imports: [JwtModule.register({}), EmailModule],
imports: [
JwtModule.register({
global: true,
}),
EmailModule,
],
controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService],

View File

@@ -2,12 +2,14 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2";
import { Request, Response } from "express";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
@@ -23,18 +25,19 @@ export class AuthService {
private config: ConfigService,
private emailService: EmailService,
) {}
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 hash = await argon.hash(dto.password);
const hash = dto.password ? await argon.hash(dto.password) : null;
try {
const user = await this.prisma.user.create({
data: {
email: dto.email,
username: dto.username,
password: hash,
isAdmin: isFirstUser,
isAdmin: isAdmin ?? isFirstUser,
},
});
@@ -43,7 +46,8 @@ export class AuthService {
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
this.logger.log(`User ${user.email} signed up from IP ${ip}`);
return { accessToken, refreshToken, user };
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
@@ -56,22 +60,37 @@ export class AuthService {
}
}
async signIn(dto: AuthSignInDTO) {
async signIn(dto: AuthSignInDTO, ip: string) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
if (this.config.get("oauth.disablePassword"))
throw new ForbiddenException("Password sign in is disabled");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
if (!user || !(await argon.verify(user.password, dto.password))) {
this.logger.log(
`Failed login attempt for user ${dto.email} from IP ${ip}`,
);
throw new UnauthorizedException("Wrong email or password");
}
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
return this.generateToken(user);
}
async generateToken(user: User, isOAuth = false) {
// TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled
if (user.totpVerified) {
if (
user.totpVerified &&
!(isOAuth && this.config.get("oauth.ignoreTotp"))
) {
const loginToken = await this.createLoginToken(user.id);
return { loginToken };
@@ -86,12 +105,15 @@ export class AuthService {
}
async requestResetPassword(email: string) {
if (this.config.get("oauth.disablePassword"))
throw new ForbiddenException("Password sign in is disabled");
const user = await this.prisma.user.findFirst({
where: { email },
include: { resetPasswordToken: true },
});
if (!user) throw new BadRequestException("User not found");
if (!user) return;
// Delete old reset password token
if (user.resetPasswordToken) {
@@ -111,6 +133,9 @@ export class AuthService {
}
async resetPassword(token: string, newPassword: string) {
if (this.config.get("oauth.disablePassword"))
throw new ForbiddenException("Password sign in is disabled");
const user = await this.prisma.user.findFirst({
where: { resetPasswordToken: { token } },
});
@@ -129,9 +154,11 @@ export class AuthService {
});
}
async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password");
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
const isPasswordValid =
!user.password || (await argon.verify(user.password, oldPassword));
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
const hash = await argon.hash(newPassword);
@@ -195,7 +222,12 @@ export class AuthService {
async createRefreshToken(userId: string) {
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(),
},
});
return { refreshTokenId: id, refreshToken: token };
@@ -210,4 +242,41 @@ export class AuthService {
return loginToken;
}
addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string,
) {
if (accessToken)
response.cookie("access_token", accessToken, {
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
});
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"),
});
}
/**
* Returns the user id if the user is logged in, null otherwise
*/
async getIdOfCurrentUser(request: Request): Promise<string | null> {
if (!request.cookies.access_token) return null;
try {
const payload = await this.jwtService.verifyAsync(
request.cookies.access_token,
{
secret: this.config.get("internal.jwtSecret"),
},
);
return payload.sub;
} catch {
return null;
}
}
}

View File

@@ -8,7 +8,6 @@ import { User } from "@prisma/client";
import * as argon from "argon2";
import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -18,47 +17,32 @@ export class AuthTotpService {
constructor(
private prisma: PrismaService,
private authService: AuthService,
private config: ConfigService,
) {}
async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({
where: {
token: dto.loginToken,
},
include: {
user: true,
},
});
if (!token || token.userId != user.id || token.used)
if (!token || token.used)
throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
const { totpSecret } = token.user;
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const expected = authenticator.generate(totpSecret);
if (dto.totp !== expected) {
if (!authenticator.check(dto.totp, totpSecret)) {
throw new BadRequestException("Invalid code");
}
@@ -69,9 +53,9 @@ export class AuthTotpService {
});
const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id);
await this.authService.createRefreshToken(token.user.id);
const accessToken = await this.authService.createAccessToken(
user,
token.user,
refreshTokenId,
);
@@ -92,12 +76,11 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is already enabled");
}
// TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret();
const otpURL = totp.keyuri(
user.username || user.email,
this.config.get("general.appName"),
"pingvin-share",
secret,
);

View File

@@ -1,7 +1,7 @@
import { IsString } from "class-validator";
import { AuthSignInDTO } from "./authSignIn.dto";
export class AuthSignInTotpDTO extends AuthSignInDTO {
export class AuthSignInTotpDTO {
@IsString()
totp: string;

View File

@@ -1,8 +1,9 @@
import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator";
import { IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
oldPassword: string;
@IsOptional()
oldPassword?: string;
}

View File

@@ -6,13 +6,20 @@ import {
} from "@nestjs/common";
import { Config } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { EventEmitter } from "events";
/**
* ConfigService extends EventEmitter to allow listening for config updates,
* now only `update` event will be emitted.
*/
@Injectable()
export class ConfigService {
export class ConfigService extends EventEmitter {
constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService,
) {}
) {
super();
}
get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter(
@@ -105,6 +112,8 @@ export class ConfigService {
this.configVariables = await this.prisma.config.findMany();
this.emit("update", key, value);
return updatedVariable;
}
}

View File

@@ -26,7 +26,7 @@ export class LogoService {
fs.promises.writeFile(
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
resized,
"binary"
"binary",
);
}
}

View File

@@ -25,6 +25,11 @@ export class EmailService {
user: this.config.get("smtp.username"),
pass: this.config.get("smtp.password"),
},
tls: {
rejectUnauthorized: !this.config.get(
"smtp.allowUnauthorizedCertificates",
),
},
});
}

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
@@ -25,18 +26,21 @@ export class FileController {
@SkipThrottle()
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async create(
@Query() query: any,
@Query()
query: {
id: string;
name: string;
chunkIndex: string;
totalChunks: string;
},
@Body() body: string,
@Param("shareId") shareId: string,
) {
const { id, name, chunkIndex, totalChunks } = query;
// Data can be empty if the file is empty
const data = body.toString().split(",")[1] ?? "";
return await this.fileService.create(
data,
body,
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
{ id, name },
shareId,
@@ -71,6 +75,7 @@ export class FileController {
const headers = {
"Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size,
"Content-Security-Policy": "script-src 'none'",
};
if (download === "true") {
@@ -81,4 +86,14 @@ export class FileController {
return new StreamableFile(file.file);
}
@Delete(":fileId")
@SkipThrottle()
@UseGuards(ShareOwnerGuard)
async remove(
@Param("fileId") fileId: string,
@Param("shareId") shareId: string,
) {
await this.fileService.remove(shareId, fileId);
}
}

View File

@@ -47,7 +47,7 @@ export class FileService {
}
// If the sent chunk index and the expected chunk index doesn't match throw an error
const chunkSize = 10 * 1024 * 1024; // 10MB
const chunkSize = this.config.get("share.chunkSize");
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
if (expectedChunkIndex != chunk.index)
@@ -124,6 +124,18 @@ export class FileService {
};
}
async remove(shareId: string, fileId: string) {
const fileMetaData = await this.prisma.file.findUnique({
where: { id: fileId },
});
if (!fileMetaData) throw new NotFoundException("File not found");
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
await this.prisma.file.delete({ where: { id: fileId } });
}
async deleteAllFiles(shareId: string) {
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true,

View File

@@ -9,14 +9,16 @@ import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { ShareService } from "src/share/share.service";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class FileSecurityGuard extends ShareSecurityGuard {
constructor(
private _shareService: ShareService,
private _prisma: PrismaService,
_config: ConfigService,
) {
super(_shareService, _prisma);
super(_shareService, _prisma, _config);
}
async canActivate(context: ExecutionContext) {

View File

@@ -61,6 +61,28 @@ export class JobsService {
}
}
@Cron("0 */6 * * *")
async deleteUnfinishedShares() {
const unfinishedShares = await this.prisma.share.findMany({
where: {
createdAt: { lt: moment().subtract(1, "day").toDate() },
uploadLocked: false,
},
});
for (const unfinishedShare of unfinishedShares) {
await this.prisma.share.delete({
where: { id: unfinishedShare.id },
});
await this.fileService.deleteAllFiles(unfinishedShare.id);
}
if (unfinishedShares.length > 0) {
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
}
}
@Cron("0 0 * * *")
deleteTemporaryFiles() {
let filesDeleted = 0;
@@ -93,7 +115,7 @@ export class JobsService {
this.logger.log(`Deleted ${filesDeleted} temporary files`);
}
@Cron("0 * * * *")
@Cron("1 * * * *")
async deleteExpiredTokens() {
const { count: refreshTokenCount } =
await this.prisma.refreshToken.deleteMany({

View File

@@ -1,11 +1,17 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import {
ClassSerializerInterceptor,
Logger,
ValidationPipe,
} from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser";
import { NextFunction, Request, Response } from "express";
import * as fs from "fs";
import { AppModule } from "./app.module";
import { ConfigService } from "./config/config.service";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() {
@@ -13,7 +19,16 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
const config = app.get<ConfigService>(ConfigService);
app.use((req: Request, res: Response, next: NextFunction) => {
const chunkSize = config.get("share.chunkSize");
bodyParser.raw({
type: "application/octet-stream",
limit: `${chunkSize}B`,
})(req, res, next);
});
app.use(cookieParser());
app.set("trust proxy", true);
@@ -34,5 +49,8 @@ async function bootstrap() {
}
await app.listen(parseInt(process.env.PORT) || 8080);
const logger = new Logger("UnhandledAsyncError");
process.on("unhandledRejection", (e) => logger.error(e));
}
bootstrap();

View File

@@ -0,0 +1,9 @@
import { IsString } from "class-validator";
export class OAuthCallbackDto {
@IsString()
code: string;
@IsString()
state: string;
}

View File

@@ -0,0 +1,7 @@
export interface OAuthSignInDto {
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
providerId: string;
providerUsername: string;
email: string;
isAdmin?: boolean;
}

View File

@@ -0,0 +1,15 @@
export class ErrorPageException extends Error {
/**
* Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix)
* @param key i18n key of message
* @param redirect redirect url
* @param params message params (key)
*/
constructor(
public readonly key: string = "default",
public readonly redirect?: string,
public readonly params?: string[],
) {
super("error");
}
}

View File

@@ -0,0 +1,39 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { ErrorPageException } from "../exceptions/errorPage.exception";
@Catch(ErrorPageException)
export class ErrorPageExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ErrorPageExceptionFilter.name);
constructor(private config: ConfigService) {}
catch(exception: ErrorPageException, host: ArgumentsHost) {
this.logger.error(
JSON.stringify({
error: exception.key,
params: exception.params,
redirect: exception.redirect,
}),
);
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const url = new URL(`${this.config.get("general.appUrl")}/error`);
url.searchParams.set("error", exception.key);
if (exception.redirect) {
url.searchParams.set("redirect", exception.redirect);
} else {
const redirect = ctx.getRequest().cookies.access_token
? "/account"
: "/auth/signIn";
url.searchParams.set("redirect", redirect);
}
if (exception.params) {
url.searchParams.set("params", exception.params.join(","));
}
response.redirect(url.toString());
}
}

View File

@@ -0,0 +1,38 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
Logger,
} from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
@Catch(HttpException)
export class OAuthExceptionFilter implements ExceptionFilter {
private errorKeys: Record<string, string> = {
access_denied: "access_denied",
expired_token: "expired_token",
};
private readonly logger = new Logger(OAuthExceptionFilter.name);
constructor(private config: ConfigService) {}
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
this.logger.error(exception.message);
this.logger.error(
"Request query: " + JSON.stringify(request.query, null, 2),
);
const key = this.errorKeys[request.query.error] || "default";
const url = new URL(`${this.config.get("general.appUrl")}/error`);
url.searchParams.set("redirect", "/account");
url.searchParams.set("error", key);
response.redirect(url.toString());
}
}

View File

@@ -0,0 +1,12 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
@Injectable()
export class OAuthGuard implements CanActivate {
constructor() {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const provider = request.params.provider;
return request.query.state === request.cookies[`oauth_${provider}_state`];
}
}

View File

@@ -0,0 +1,24 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
@Injectable()
export class ProviderGuard implements CanActivate {
constructor(
private config: ConfigService,
@Inject("OAUTH_PLATFORMS") private platforms: string[],
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const provider = request.params.provider;
return (
this.platforms.includes(provider) &&
this.config.get(`oauth.${provider}-enabled`)
);
}
}

View File

@@ -0,0 +1,110 @@
import {
Controller,
Get,
Inject,
Param,
Post,
Query,
Req,
Res,
UseFilters,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import { nanoid } from "nanoid";
import { AuthService } from "../auth/auth.service";
import { GetUser } from "../auth/decorator/getUser.decorator";
import { JwtGuard } from "../auth/guard/jwt.guard";
import { ConfigService } from "../config/config.service";
import { OAuthCallbackDto } from "./dto/oauthCallback.dto";
import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter";
import { OAuthGuard } from "./guard/oauth.guard";
import { ProviderGuard } from "./guard/provider.guard";
import { OAuthService } from "./oauth.service";
import { OAuthProvider } from "./provider/oauthProvider.interface";
import { OAuthExceptionFilter } from "./filter/oauthException.filter";
@Controller("oauth")
export class OAuthController {
constructor(
private authService: AuthService,
private oauthService: OAuthService,
private config: ConfigService,
@Inject("OAUTH_PROVIDERS")
private providers: Record<string, OAuthProvider<unknown>>,
) {}
@Get("available")
available() {
return this.oauthService.available();
}
@Get("status")
@UseGuards(JwtGuard)
async status(@GetUser() user: User) {
return this.oauthService.status(user);
}
@Get("auth/:provider")
@UseGuards(ProviderGuard)
@UseFilters(ErrorPageExceptionFilter)
async auth(
@Param("provider") provider: string,
@Res({ passthrough: true }) response: Response,
) {
const state = nanoid(16);
const url = await this.providers[provider].getAuthEndpoint(state);
response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" });
response.redirect(url);
}
@Get("callback/:provider")
@UseGuards(ProviderGuard, OAuthGuard)
@UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter)
async callback(
@Param("provider") provider: string,
@Query() query: OAuthCallbackDto,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const oauthToken = await this.providers[provider].getToken(query);
const user = await this.providers[provider].getUserInfo(oauthToken, query);
const id = await this.authService.getIdOfCurrentUser(request);
if (id) {
await this.oauthService.link(
id,
provider,
user.providerId,
user.providerUsername,
);
response.redirect(this.config.get("general.appUrl") + "/account");
} else {
const token: {
accessToken?: string;
refreshToken?: string;
loginToken?: string;
} = await this.oauthService.signIn(user, request.ip);
if (token.accessToken) {
this.authService.addTokensToResponse(
response,
token.refreshToken,
token.accessToken,
);
response.redirect(this.config.get("general.appUrl"));
} else {
response.redirect(
this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`,
);
}
}
}
@Post("unlink/:provider")
@UseGuards(JwtGuard, ProviderGuard)
@UseFilters(ErrorPageExceptionFilter)
unlink(@GetUser() user: User, @Param("provider") provider: string) {
return this.oauthService.unlink(user, provider);
}
}

View File

@@ -0,0 +1,56 @@
import { Module } from "@nestjs/common";
import { OAuthController } from "./oauth.controller";
import { OAuthService } from "./oauth.service";
import { AuthModule } from "../auth/auth.module";
import { GitHubProvider } from "./provider/github.provider";
import { GoogleProvider } from "./provider/google.provider";
import { OAuthProvider } from "./provider/oauthProvider.interface";
import { OidcProvider } from "./provider/oidc.provider";
import { DiscordProvider } from "./provider/discord.provider";
import { MicrosoftProvider } from "./provider/microsoft.provider";
@Module({
controllers: [OAuthController],
providers: [
OAuthService,
GitHubProvider,
GoogleProvider,
MicrosoftProvider,
DiscordProvider,
OidcProvider,
{
provide: "OAUTH_PROVIDERS",
useFactory(
github: GitHubProvider,
google: GoogleProvider,
microsoft: MicrosoftProvider,
discord: DiscordProvider,
oidc: OidcProvider,
): Record<string, OAuthProvider<unknown>> {
return {
github,
google,
microsoft,
discord,
oidc,
};
},
inject: [
GitHubProvider,
GoogleProvider,
MicrosoftProvider,
DiscordProvider,
OidcProvider,
],
},
{
provide: "OAUTH_PLATFORMS",
useFactory(providers: Record<string, OAuthProvider<unknown>>): string[] {
return Object.keys(providers);
},
inject: ["OAUTH_PROVIDERS"],
},
],
imports: [AuthModule],
})
export class OAuthModule {}

View File

@@ -0,0 +1,193 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { User } from "@prisma/client";
import { nanoid } from "nanoid";
import { AuthService } from "../auth/auth.service";
import { ConfigService } from "../config/config.service";
import { PrismaService } from "../prisma/prisma.service";
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
import { ErrorPageException } from "./exceptions/errorPage.exception";
@Injectable()
export class OAuthService {
constructor(
private prisma: PrismaService,
private config: ConfigService,
private auth: AuthService,
@Inject("OAUTH_PLATFORMS") private platforms: string[],
) {}
private readonly logger = new Logger(OAuthService.name);
available(): string[] {
return this.platforms
.map((platform) => [
platform,
this.config.get(`oauth.${platform}-enabled`),
])
.filter(([_, enabled]) => enabled)
.map(([platform, _]) => platform);
}
async status(user: User) {
const oauthUsers = await this.prisma.oAuthUser.findMany({
select: {
provider: true,
providerUsername: true,
},
where: {
userId: user.id,
},
});
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
}
async signIn(user: OAuthSignInDto, ip: string) {
const oauthUser = await this.prisma.oAuthUser.findFirst({
where: {
provider: user.provider,
providerUserId: user.providerId,
},
});
if (oauthUser) {
await this.updateIsAdmin(user);
const updatedUser = await this.prisma.user.findFirst({
where: {
email: user.email,
},
});
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
return this.auth.generateToken(updatedUser, true);
}
return this.signUp(user, ip);
}
async link(
userId: string,
provider: string,
providerUserId: string,
providerUsername: string,
) {
const oauthUser = await this.prisma.oAuthUser.findFirst({
where: {
provider,
providerUserId,
},
});
if (oauthUser) {
throw new ErrorPageException("already_linked", "/account", [
`provider_${provider}`,
]);
}
await this.prisma.oAuthUser.create({
data: {
userId,
provider,
providerUsername,
providerUserId,
},
});
}
async unlink(user: User, provider: string) {
const oauthUser = await this.prisma.oAuthUser.findFirst({
where: {
userId: user.id,
provider,
},
});
if (oauthUser) {
await this.prisma.oAuthUser.delete({
where: {
id: oauthUser.id,
},
});
} else {
throw new ErrorPageException("not_linked", "/account", [provider]);
}
}
private async getAvailableUsername(preferredUsername: string) {
// only remove + and - from preferred username for now (maybe not enough)
let username = preferredUsername.replace(/[+-]/g, "").substring(0, 20);
while (true) {
const user = await this.prisma.user.findFirst({
where: {
username: username,
},
});
if (user) {
username = username + "_" + nanoid(10).replaceAll("-", "");
} else {
return username;
}
}
}
private async signUp(user: OAuthSignInDto, ip: string) {
// register
if (!this.config.get("oauth.allowRegistration")) {
throw new ErrorPageException("no_user", "/auth/signIn", [
`provider_${user.provider}`,
]);
}
if (!user.email) {
throw new ErrorPageException("no_email", "/auth/signIn", [
`provider_${user.provider}`,
]);
}
const existingUser: User = await this.prisma.user.findFirst({
where: {
email: user.email,
},
});
if (existingUser) {
await this.prisma.oAuthUser.create({
data: {
provider: user.provider,
providerUserId: user.providerId.toString(),
providerUsername: user.providerUsername,
userId: existingUser.id,
},
});
await this.updateIsAdmin(user);
return this.auth.generateToken(existingUser, true);
}
const result = await this.auth.signUp(
{
email: user.email,
username: await this.getAvailableUsername(user.providerUsername),
password: null,
},
ip,
user.isAdmin,
);
await this.prisma.oAuthUser.create({
data: {
provider: user.provider,
providerUserId: user.providerId.toString(),
providerUsername: user.providerUsername,
userId: result.user.id,
},
});
return result;
}
private async updateIsAdmin(user: OAuthSignInDto) {
if ("isAdmin" in user)
await this.prisma.user.update({
where: {
email: user.email,
},
data: {
isAdmin: user.isAdmin,
},
});
}
}

View File

@@ -0,0 +1,135 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class DiscordProvider implements OAuthProvider<DiscordToken> {
constructor(private config: ConfigService) {}
getAuthEndpoint(state: string): Promise<string> {
let scope = "identify email";
if (this.config.get("oauth.discord-limitedGuild")) {
scope += " guilds";
}
return Promise.resolve(
"https://discord.com/api/oauth2/authorize?" +
new URLSearchParams({
client_id: this.config.get("oauth.discord-clientId"),
redirect_uri:
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
response_type: "code",
state,
scope,
}).toString(),
);
}
private getAuthorizationHeader() {
return (
"Basic " +
Buffer.from(
this.config.get("oauth.discord-clientId") +
":" +
this.config.get("oauth.discord-clientSecret"),
).toString("base64")
);
}
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<DiscordToken>> {
const res = await fetch("https://discord.com/api/v10/oauth2/token", {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: this.getAuthorizationHeader(),
},
body: new URLSearchParams({
code: query.code,
grant_type: "authorization_code",
redirect_uri:
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
}),
});
const token = (await res.json()) as DiscordToken;
return {
accessToken: token.access_token,
refreshToken: token.refresh_token,
expiresIn: token.expires_in,
scope: token.scope,
tokenType: token.token_type,
rawToken: token,
};
}
async getUserInfo(token: OAuthToken<DiscordToken>): Promise<OAuthSignInDto> {
const res = await fetch("https://discord.com/api/v10/users/@me", {
method: "get",
headers: {
Accept: "application/json",
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
},
});
const user = (await res.json()) as DiscordUser;
if (user.verified === false) {
throw new ErrorPageException("unverified_account", undefined, [
"provider_discord",
]);
}
const guild = this.config.get("oauth.discord-limitedGuild");
if (guild) {
await this.checkLimitedGuild(token, guild);
}
return {
provider: "discord",
providerId: user.id,
providerUsername: user.global_name ?? user.username,
email: user.email,
};
}
async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
try {
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
method: "get",
headers: {
Accept: "application/json",
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
},
});
const guilds = (await res.json()) as DiscordPartialGuild[];
if (!guilds.some((guild) => guild.id === guildId)) {
throw new ErrorPageException("user_not_allowed");
}
} catch {
throw new ErrorPageException("user_not_allowed");
}
}
}
export interface DiscordToken {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
export interface DiscordUser {
id: string;
username: string;
global_name: string;
email: string;
verified: boolean;
}
export interface DiscordPartialGuild {
id: string;
name: string;
icon: string;
owner: boolean;
permissions: string;
features: string[];
}

View File

@@ -0,0 +1,281 @@
import { Logger } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
import { Cache } from "cache-manager";
import * as jmespath from "jmespath";
import { nanoid } from "nanoid";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ErrorPageException } from "../exceptions/errorPage.exception";
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
protected discoveryUri: string;
private configuration: OidcConfigurationCache;
private jwk: OidcJwkCache;
private logger: Logger = new Logger(
Object.getPrototypeOf(this).constructor.name,
);
protected constructor(
protected name: string,
protected keyOfConfigUpdateEvents: string[],
protected config: ConfigService,
protected jwtService: JwtService,
protected cache: Cache,
) {
this.discoveryUri = this.getDiscoveryUri();
this.config.addListener("update", (key: string) => {
if (this.keyOfConfigUpdateEvents.includes(key)) {
this.deinit();
this.discoveryUri = this.getDiscoveryUri();
}
});
}
protected getRedirectUri(): string {
return `${this.config.get("general.appUrl")}/api/oauth/callback/${
this.name
}`;
}
async getConfiguration(): Promise<OidcConfiguration> {
if (!this.configuration || this.configuration.expires < Date.now()) {
await this.fetchConfiguration();
}
return this.configuration.data;
}
async getJwk(): Promise<OidcJwk[]> {
if (!this.jwk || this.jwk.expires < Date.now()) {
await this.fetchJwk();
}
return this.jwk.data;
}
async getAuthEndpoint(state: string) {
const configuration = await this.getConfiguration();
const endpoint = configuration.authorization_endpoint;
const nonce = nanoid();
await this.cache.set(
`oauth-${this.name}-nonce-${state}`,
nonce,
1000 * 60 * 5,
);
return (
endpoint +
"?" +
new URLSearchParams({
client_id: this.config.get(`oauth.${this.name}-clientId`),
response_type: "code",
scope: "openid profile email",
redirect_uri: this.getRedirectUri(),
state,
nonce,
}).toString()
);
}
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<OidcToken>> {
const configuration = await this.getConfiguration();
const endpoint = configuration.token_endpoint;
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: this.config.get(`oauth.${this.name}-clientId`),
client_secret: this.config.get(`oauth.${this.name}-clientSecret`),
grant_type: "authorization_code",
code: query.code,
redirect_uri: this.getRedirectUri(),
}).toString(),
});
const token = (await res.json()) as OidcToken;
return {
accessToken: token.access_token,
expiresIn: token.expires_in,
idToken: token.id_token,
refreshToken: token.refresh_token,
tokenType: token.token_type,
rawToken: token,
};
}
async getUserInfo(
token: OAuthToken<OidcToken>,
query: OAuthCallbackDto,
claim?: string,
roleConfig?: {
path?: string;
generalAccess?: string;
adminAccess?: string;
},
): Promise<OAuthSignInDto> {
const idTokenData = this.decodeIdToken(token.idToken);
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
const key = `oauth-${this.name}-nonce-${query.state}`;
const nonce = await this.cache.get(key);
await this.cache.del(key);
if (nonce !== idTokenData.nonce) {
this.logger.error(
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
);
throw new ErrorPageException("invalid_token");
}
const username = claim
? idTokenData[claim]
: idTokenData.preferred_username ||
idTokenData.name ||
idTokenData.nickname;
let isAdmin: boolean;
if (roleConfig?.path) {
// A path to read roles from the token is configured
let roles: string[] | null;
try {
roles = jmespath.search(idTokenData, roleConfig.path);
} catch (e) {
roles = null;
}
if (Array.isArray(roles)) {
// Roles are found in the token
if (
roleConfig.generalAccess &&
!roles.includes(roleConfig.generalAccess)
) {
// Role for general access is configured and the user does not have it
this.logger.error(
`User roles ${roles} do not include ${roleConfig.generalAccess}`,
);
throw new ErrorPageException("user_not_allowed");
}
if (roleConfig.adminAccess) {
// Role for admin access is configured
isAdmin = roles.includes(roleConfig.adminAccess);
}
} else {
this.logger.error(
`Roles not found at path ${roleConfig.path} in ID Token ${JSON.stringify(
idTokenData,
undefined,
2,
)}`,
);
throw new ErrorPageException("user_not_allowed");
}
}
if (!username) {
this.logger.error(
`Can not get username from ID Token ${JSON.stringify(
idTokenData,
undefined,
2,
)}`,
);
throw new ErrorPageException("cannot_get_user_info", undefined, [
`provider_${this.name}`,
]);
}
return {
provider: this.name as any,
email: idTokenData.email,
providerId: idTokenData.sub,
providerUsername: username,
...(isAdmin !== undefined && { isAdmin }),
};
}
protected abstract getDiscoveryUri(): string;
private async fetchConfiguration(): Promise<void> {
const res = await fetch(this.discoveryUri);
const expires = res.headers.has("expires")
? new Date(res.headers.get("expires")).getTime()
: Date.now() + 1000 * 60 * 60 * 24;
this.configuration = {
expires,
data: (await res.json()) as OidcConfiguration,
};
}
private async fetchJwk(): Promise<void> {
const configuration = await this.getConfiguration();
const res = await fetch(configuration.jwks_uri);
const expires = res.headers.has("expires")
? new Date(res.headers.get("expires")).getTime()
: Date.now() + 1000 * 60 * 60 * 24;
this.jwk = {
expires,
data: (await res.json())["keys"],
};
}
private deinit() {
this.discoveryUri = undefined;
this.configuration = undefined;
this.jwk = undefined;
}
private decodeIdToken(idToken: string): OidcIdToken {
return this.jwtService.decode(idToken) as OidcIdToken;
}
}
export interface OidcCache<T> {
expires: number;
data: T;
}
export interface OidcConfiguration {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri: string;
response_types_supported: string[];
id_token_signing_alg_values_supported: string[];
scopes_supported?: string[];
claims_supported?: string[];
}
export interface OidcJwk {
e: string;
alg: string;
kid: string;
use: string;
kty: string;
n: string;
}
export type OidcConfigurationCache = OidcCache<OidcConfiguration>;
export type OidcJwkCache = OidcCache<OidcJwk[]>;
export interface OidcToken {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
id_token: string;
}
export interface OidcIdToken {
iss: string;
sub: string;
exp: number;
iat: number;
email: string;
name: string;
nickname: string;
preferred_username: string;
nonce: string;
}

View File

@@ -0,0 +1,111 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class GitHubProvider implements OAuthProvider<GitHubToken> {
constructor(private config: ConfigService) {}
getAuthEndpoint(state: string): Promise<string> {
return Promise.resolve(
"https://github.com/login/oauth/authorize?" +
new URLSearchParams({
client_id: this.config.get("oauth.github-clientId"),
redirect_uri:
this.config.get("general.appUrl") + "/api/oauth/callback/github",
state: state,
scope: "user:email",
}).toString(),
);
}
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<GitHubToken>> {
const res = await fetch(
"https://github.com/login/oauth/access_token?" +
new URLSearchParams({
client_id: this.config.get("oauth.github-clientId"),
client_secret: this.config.get("oauth.github-clientSecret"),
code: query.code,
}).toString(),
{
method: "post",
headers: {
Accept: "application/json",
},
},
);
const token = (await res.json()) as GitHubToken;
return {
accessToken: token.access_token,
tokenType: token.token_type,
scope: token.scope,
rawToken: token,
};
}
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
if (!token.scope.includes("user:email")) {
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
}
const user = await this.getGitHubUser(token);
const email = await this.getGitHubEmail(token);
if (!email) {
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
}
return {
provider: "github",
providerId: user.id.toString(),
providerUsername: user.name ?? user.login,
email,
};
}
private async getGitHubUser(
token: OAuthToken<GitHubToken>,
): Promise<GitHubUser> {
const res = await fetch("https://api.github.com/user", {
headers: {
Accept: "application/vnd.github+json",
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
},
});
return (await res.json()) as GitHubUser;
}
private async getGitHubEmail(
token: OAuthToken<GitHubToken>,
): Promise<string | undefined> {
const res = await fetch("https://api.github.com/user/public_emails", {
headers: {
Accept: "application/vnd.github+json",
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
},
});
const emails = (await res.json()) as GitHubEmail[];
return emails.find((e) => e.primary && e.verified)?.email;
}
}
export interface GitHubToken {
access_token: string;
token_type: string;
scope: string;
}
export interface GitHubUser {
login: string;
id: number;
name?: string;
email?: string; // this filed seems only return null
}
export interface GitHubEmail {
email: string;
primary: boolean;
verified: boolean;
visibility: string | null;
}

View File

@@ -0,0 +1,21 @@
import { GenericOidcProvider } from "./genericOidc.provider";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
import { Inject, Injectable } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
@Injectable()
export class GoogleProvider extends GenericOidcProvider {
constructor(
config: ConfigService,
jwtService: JwtService,
@Inject(CACHE_MANAGER) cache: Cache,
) {
super("google", ["oauth.google-enabled"], config, jwtService, cache);
}
protected getDiscoveryUri(): string {
return "https://accounts.google.com/.well-known/openid-configuration";
}
}

View File

@@ -0,0 +1,29 @@
import { GenericOidcProvider } from "./genericOidc.provider";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
import { Inject, Injectable } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
@Injectable()
export class MicrosoftProvider extends GenericOidcProvider {
constructor(
config: ConfigService,
jwtService: JwtService,
@Inject(CACHE_MANAGER) cache: Cache,
) {
super(
"microsoft",
["oauth.microsoft-enabled", "oauth.microsoft-tenant"],
config,
jwtService,
cache,
);
}
protected getDiscoveryUri(): string {
return `https://login.microsoftonline.com/${this.config.get(
"oauth.microsoft-tenant",
)}/v2.0/.well-known/openid-configuration`;
}
}

View File

@@ -0,0 +1,24 @@
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
/**
* @typeParam T - type of token
* @typeParam C - type of callback query
*/
export interface OAuthProvider<T, C = OAuthCallbackDto> {
getAuthEndpoint(state: string): Promise<string>;
getToken(query: C): Promise<OAuthToken<T>>;
getUserInfo(token: OAuthToken<T>, query: C): Promise<OAuthSignInDto>;
}
export interface OAuthToken<T> {
accessToken: string;
expiresIn?: number;
refreshToken?: string;
tokenType?: string;
scope?: string;
idToken?: string;
rawToken: T;
}

View File

@@ -0,0 +1,48 @@
import { GenericOidcProvider, OidcToken } from "./genericOidc.provider";
import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class OidcProvider extends GenericOidcProvider {
constructor(
config: ConfigService,
jwtService: JwtService,
@Inject(CACHE_MANAGER) protected cache: Cache,
) {
super(
"oidc",
["oauth.oidc-enabled", "oauth.oidc-discoveryUri"],
config,
jwtService,
cache,
);
}
protected getDiscoveryUri(): string {
return this.config.get("oauth.oidc-discoveryUri");
}
getUserInfo(
token: OAuthToken<OidcToken>,
query: OAuthCallbackDto,
_?: string,
): Promise<OAuthSignInDto> {
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
const rolePath = this.config.get("oauth.oidc-rolePath") || undefined;
const roleGeneralAccess =
this.config.get("oauth.oidc-roleGeneralAccess") || undefined;
const roleAdminAccess =
this.config.get("oauth.oidc-roleAdminAccess") || undefined;
return super.getUserInfo(token, query, claim, {
path: rolePath,
generalAccess: roleGeneralAccess,
adminAccess: roleAdminAccess,
});
}
}

View File

@@ -13,4 +13,10 @@ export class CreateReverseShareDTO {
@Min(1)
@Max(1000)
maxUseCount: number;
@IsBoolean()
simplified: boolean;
@IsBoolean()
publicAccess: boolean;
}

View File

@@ -13,6 +13,9 @@ export class ReverseShareDTO {
@Expose()
token: string;
@Expose()
simplified: boolean;
from(partial: Partial<ReverseShareDTO>) {
return plainToClass(ReverseShareDTO, partial, {
excludeExtraneousValues: true,

View File

@@ -13,7 +13,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
shares: Omit<
MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword"
"recipients" | "files" | "from" | "fromList" | "hasPassword" | "size"
>[];
@Expose()

View File

@@ -36,7 +36,12 @@ export class ReverseShareController {
return { token, link };
}
@Throttle(20, 60)
@Throttle({
default: {
limit: 20,
ttl: 60,
},
})
@Get(":reverseShareToken")
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
const isValid = await this.reverseShareService.isValid(reverseShareToken);

View File

@@ -3,6 +3,7 @@ import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
@Injectable()
@@ -24,6 +25,17 @@ export class ReverseShareService {
)
.toDate();
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
if (
this.config.get("share.maxExpiration") !== 0 &&
parsedExpiration >
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
) {
throw new BadRequestException(
"Expiration date exceeds maximum expiration date",
);
}
const globalMaxShareSize = this.config.get("share.maxSize");
if (globalMaxShareSize < data.maxShareSize)
@@ -37,6 +49,8 @@ export class ReverseShareService {
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification,
simplified: data.simplified,
publicAccess: data.publicAccess,
creatorId,
},
});

View 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 }),
);
}
}

View File

@@ -18,6 +18,10 @@ export class CreateShareDTO {
@Length(3, 50)
id: string;
@Length(3, 30)
@IsOptional()
name: string;
@IsString()
expiration: string;

View File

@@ -6,6 +6,9 @@ export class ShareDTO {
@Expose()
id: string;
@Expose()
name?: string;
@Expose()
expiration: Date;
@@ -23,6 +26,9 @@ export class ShareDTO {
@Expose()
hasPassword: boolean;
@Expose()
size: number;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
}

View 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 }),
);
}
}

View File

@@ -20,9 +20,8 @@ export class CreateShareGuard extends JwtGuard {
if (!reverseShareTokenId) return false;
const isReverseShareTokenValid = await this.reverseShareService.isValid(
reverseShareTokenId,
);
const isReverseShareTokenValid =
await this.reverseShareService.isValid(reverseShareTokenId);
return isReverseShareTokenValid;
}

View File

@@ -1,16 +1,22 @@
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { JwtGuard } from "../../auth/guard/jwt.guard";
@Injectable()
export class ShareOwnerGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
export class ShareOwnerGuard extends JwtGuard {
constructor(
configService: ConfigService,
private prisma: PrismaService,
) {
super(configService);
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -28,8 +34,20 @@ export class ShareOwnerGuard implements CanActivate {
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;
return share.creatorId == (request.user as User).id;
// If not signed in, deny access
if (!user) return false;
// If the user is the creator of the share, allow access
return share.creatorId == user.id;
}
}

View File

@@ -1,5 +1,4 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
@@ -9,13 +8,19 @@ import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
import { ConfigService } from "src/config/config.service";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { User } from "@prisma/client";
@Injectable()
export class ShareSecurityGuard implements CanActivate {
export class ShareSecurityGuard extends JwtGuard {
constructor(
private shareService: ShareService,
private prisma: PrismaService,
) {}
configService: ConfigService,
) {
super(configService);
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
include: { security: true, reverseShare: true },
});
if (
@@ -53,6 +58,22 @@ export class ShareSecurityGuard implements CanActivate {
"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;
}
}

View File

@@ -10,11 +10,15 @@ import {
Res,
UseGuards,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import * as moment from "moment";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { AdminShareDTO } from "./dto/adminShare.dto";
import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto";
@@ -25,9 +29,19 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service";
import { CompletedShareDTO } from "./dto/shareComplete.dto";
@Controller("shares")
export class ShareController {
constructor(private shareService: ShareService) {}
constructor(
private shareService: ShareService,
private jwtService: JwtService,
) {}
@Get("all")
@UseGuards(JwtGuard, AdministratorGuard)
async getAllShares() {
return new AdminShareDTO().fromList(await this.shareService.getShares());
}
@Get()
@UseGuards(JwtGuard)
@@ -43,6 +57,12 @@ export class ShareController {
return new ShareDTO().from(await this.shareService.get(id));
}
@Get(":id/from-owner")
@UseGuards(ShareOwnerGuard)
async getFromOwner(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.get(id));
}
@Get(":id/metaData")
@UseGuards(ShareSecurityGuard)
async getMetaData(@Param("id") id: string) {
@@ -62,38 +82,58 @@ export class ShareController {
);
}
@Delete(":id")
@UseGuards(JwtGuard, ShareOwnerGuard)
async remove(@Param("id") id: string) {
await this.shareService.remove(id);
}
@Post(":id/complete")
@HttpCode(202)
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
return new CompletedShareDTO().from(
await this.shareService.complete(id, reverse_share_token),
);
}
@Throttle(10, 60)
@Delete(":id/complete")
@UseGuards(ShareOwnerGuard)
async revertComplete(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.revertComplete(id));
}
@Delete(":id")
@UseGuards(ShareOwnerGuard)
async remove(@Param("id") id: string, @GetUser() user: User) {
const isDeleterAdmin = user?.isAdmin === true;
await this.shareService.remove(id, isDeleterAdmin);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id);
}
@HttpCode(200)
@Throttle(20, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@UseGuards(ShareTokenSecurity)
@Post(":id/token")
async getShareToken(
@Param("id") id: string,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto,
) {
const token = await this.shareService.getShareToken(id, body.password);
this.clearShareTokenCookies(request, response);
response.cookie(`share_${id}_token`, token, {
path: "/",
httpOnly: true,
@@ -101,4 +141,32 @@ export class ShareController {
return { token };
}
/**
* Keeps the 10 most recent share token cookies and deletes the rest and all expired ones
*/
private clearShareTokenCookies(request: Request, response: Response) {
const shareTokenCookies = Object.entries(request.cookies)
.filter(([key]) => key.startsWith("share_") && key.endsWith("_token"))
.map(([key, value]) => ({
key,
payload: this.jwtService.decode(value),
}));
const expiredTokens = shareTokenCookies.filter(
(cookie) => cookie.payload.exp < moment().unix(),
);
const validTokens = shareTokenCookies.filter(
(cookie) => cookie.payload.exp >= moment().unix(),
);
expiredTokens.forEach((cookie) => response.clearCookie(cookie.key));
if (validTokens.length > 10) {
validTokens
.sort((a, b) => a.payload.exp - b.payload.exp)
.slice(0, -10)
.forEach((cookie) => response.clearCookie(cookie.key));
}
}
}

View File

@@ -11,7 +11,7 @@ import { ShareService } from "./share.service";
imports: [
JwtModule.register({}),
EmailModule,
ClamScanModule,
forwardRef(() => ClamScanModule),
ReverseShareModule,
forwardRef(() => FileModule),
],

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
import { Share, User } from "@prisma/client";
import * as archiver from "archiver";
import * as argon from "argon2";
@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto";
@@ -45,25 +46,29 @@ export class ShareService {
let expirationDate: Date;
// If share is created by a reverse share token override the expiration date
const reverseShare = await this.reverseShareService.getByToken(
reverseShareToken,
);
const reverseShare =
await this.reverseShareService.getByToken(reverseShareToken);
if (reverseShare) {
expirationDate = reverseShare.shareExpiration;
} else {
// We have to add an exception for "never" (since moment won't like that)
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-",
)[1] as moment.unitOfTime.DurationConstructor,
)
.toDate();
} else {
expirationDate = moment(0).toDate();
const parsedExpiration = parseRelativeDateToAbsolute(share.expiration);
const expiresNever = moment(0).toDate() == parsedExpiration;
if (
this.config.get("share.maxExpiration") !== 0 &&
(expiresNever ||
parsedExpiration >
moment()
.add(this.config.get("share.maxExpiration"), "hours")
.toDate())
) {
throw new BadRequestException(
"Expiration date exceeds maximum expiration date",
);
}
expirationDate = parsedExpiration;
}
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
@@ -154,11 +159,12 @@ export class ShareService {
);
}
if (
share.reverseShare &&
this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
) {
const notifyReverseShareCreator = share.reverseShare
? this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
: undefined;
if (notifyReverseShareCreator) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id,
@@ -175,10 +181,38 @@ export class ShareService {
});
}
return this.prisma.share.update({
const updatedShare = await this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
});
return {
...updatedShare,
notifyReverseShareCreator,
};
}
async revertComplete(id: string) {
return this.prisma.share.update({
where: { id },
data: { uploadLocked: false, isZipReady: false },
});
}
async getShares() {
const shares = await this.prisma.share.findMany({
orderBy: {
expiration: "desc",
},
include: { files: true, creator: true },
});
return shares.map((share) => {
return {
...share,
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
};
});
}
async getSharesByUser(userId: string) {
@@ -201,6 +235,7 @@ export class ShareService {
return shares.map((share) => {
return {
...share,
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
recipients: share.recipients.map((recipients) => recipients.email),
};
});
@@ -210,7 +245,11 @@ export class ShareService {
const share = await this.prisma.share.findUnique({
where: { id },
include: {
files: true,
files: {
orderBy: {
name: "asc",
},
},
creator: true,
security: true,
},
@@ -238,13 +277,14 @@ export class ShareService {
return share;
}
async remove(shareId: string) {
async remove(shareId: string, isDeleterAdmin = false) {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
});
if (!share) throw new NotFoundException("Share not found");
if (!share.creatorId)
if (!share.creatorId && !isDeleterAdmin)
throw new ForbiddenException("Anonymous shares can't be deleted");
await this.fileService.deleteAllFiles(shareId);
@@ -298,15 +338,21 @@ export class ShareService {
const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId },
});
return this.jwtService.sign(
{
shareId,
},
{
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("internal.jwtSecret"),
},
);
const tokenPayload = {
shareId,
iat: moment().unix(),
};
const tokenOptions: JwtSignOptions = {
secret: this.config.get("internal.jwtSecret"),
};
if (!moment(expiration).isSame(0)) {
tokenOptions.expiresIn = moment(expiration).diff(new Date(), "seconds");
}
return this.jwtService.sign(tokenPayload, tokenOptions);
}
async verifyShareToken(shareId: string, token: string) {

View File

@@ -1,6 +1,6 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { PartialType, PickType } from "@nestjs/swagger";
import { UserDTO } from "./user.dto";
export class UpdateOwnUserDTO extends PartialType(
OmitType(UserDTO, ["isAdmin", "password"] as const),
PickType(UserDTO, ["username", "email"] as const),
) {}

View File

@@ -16,6 +16,9 @@ export class UserDTO {
@IsEmail()
email: string;
@Expose()
hasPassword: boolean;
@MinLength(8)
password: string;

View File

@@ -27,8 +27,11 @@ export class UserController {
// Own user operations
@Get("me")
@UseGuards(JwtGuard)
async getCurrentUser(@GetUser() user: User) {
return new UserDTO().from(user);
async getCurrentUser(@GetUser() user?: User) {
if (!user) return null;
const userDTO = new UserDTO().from(user);
userDTO.hasPassword = !!user.password;
return userDTO;
}
@Patch("me")

View File

@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { EmailModule } from "src/email/email.module";
import { UserController } from "./user.controller";
import { UserSevice } from "./user.service";
import { FileModule } from "src/file/file.module";
@Module({
imports: [EmailModule],
imports: [EmailModule, FileModule],
providers: [UserSevice],
controllers: [UserController],
})

View File

@@ -4,6 +4,7 @@ import * as argon from "argon2";
import * as crypto from "crypto";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service";
import { FileService } from "../file/file.service";
import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto";
@@ -12,6 +13,7 @@ export class UserSevice {
constructor(
private prisma: PrismaService,
private emailService: EmailService,
private fileService: FileService,
) {}
async list() {
@@ -74,6 +76,16 @@ export class UserSevice {
}
async delete(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: { shares: true },
});
if (!user) throw new BadRequestException("User not found");
await Promise.all(
user.shares.map((share) => this.fileService.deleteAllFiles(share.id)),
);
return await this.prisma.user.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,12 @@
import * as moment from "moment";
export function parseRelativeDateToAbsolute(relativeDate: string) {
if (relativeDate == "never") return moment(0).toDate();
return moment()
.add(
relativeDate.split("-")[0],
relativeDate.split("-")[1] as moment.unitOfTime.DurationConstructor,
)
.toDate();
}

View File

@@ -432,7 +432,7 @@
" const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"id\")",
" pm.expect(responseBody).to.have.property(\"expiration\")",
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
"});",
""
],
@@ -626,7 +626,7 @@
" const responseBody = pm.response.json();",
" pm.expect(responseBody).to.have.property(\"id\")",
" pm.expect(responseBody).to.have.property(\"expiration\")",
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
"});",
""
],

View File

@@ -6,7 +6,10 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"target": "es2021",
"lib": [
"ES2021"
],
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
clamav:
restart: unless-stopped

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
pingvin-share:
image: stonith404/pingvin-share

View File

@@ -2,7 +2,7 @@
---
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
_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)_
---
@@ -20,7 +20,8 @@ Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alt
## 🐧 Conoce Pingvin Share
- [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Reseña por DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
- [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"/>

158
docs/README.ja-jp.md Normal file
View File

@@ -0,0 +1,158 @@
# <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)を確認して、是非参加してください。

View File

@@ -2,7 +2,7 @@
---
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md), [日本语](/docs/README.ja-jp.md)_
---

157
docs/oauth2-guide.md Normal file
View File

@@ -0,0 +1,157 @@
# OAuth 2 Login Guide
## Config Built-in OAuth 2 Providers
- [GitHub](#github)
- [Google](#google)
- [Microsoft](#microsoft)
- [Discord](#discord)
- [OpenID Connect](#openid-connect)
### GitHub
Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) to create an OAuth app.
Redirect URL: `https://<your-domain>/api/oauth/callback/github`
### Google
Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to create an OAuth 2.0 App.
Redirect URL: `https://<your-domain>/api/oauth/callback/google`
### Microsoft
Please follow the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) to register an application.
> [!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.
Redirect URL: `https://<your-domain>/api/oauth/callback/microsoft`
### Discord
Create an application on [Discord Developer Portal](https://discord.com/developers/applications).
Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
### OpenID Connect
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`
## Custom your OAuth 2 Provider
If our built-in providers don't meet your needs, you can create your own OAuth 2 provider.
### 1. Create config
Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts):
```ts
const configVariables: ConfigVariables = {
// ...
oauth: {
// ...
"YOUR_PROVIDER_NAME-enabled": {
type: "boolean",
defaultValue: "false",
},
"YOUR_PROVIDER_NAME-clientId": {
type: "string",
defaultValue: "",
},
"YOUR_PROVIDER_NAME-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
}
}
```
### 2. Create provider class
#### Generic OpenID Connect
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
The [Google provider](../backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples.
Here are some discovery URIs for popular providers:
- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration`
- Google: `https://accounts.google.com/.well-known/openid-configuration`
- Apple: `https://appleid.apple.com/.well-known/openid-configuration`
- Gitlab: `https://gitlab.com/.well-known/openid-configuration`
- Huawei: `https://oauth-login.cloud.huawei.com/.well-known/openid-configuration`
- Paypal: `https://www.paypal.com/.well-known/openid-configuration`
- Yahoo: `https://api.login.yahoo.com/.well-known/openid-configuration`
#### OAuth 2
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples.
### 3. Register provider
Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts):
```ts
@Module({
providers: [
GitHubProvider,
// your provider
{
provide: "OAUTH_PROVIDERS",
useFactory(github: GitHubProvider, /* your provider */): Record<string, OAuthProvider<unknown>> {
return {
github,
/* your provider */
};
},
inject: [GitHubProvider, /* your provider */],
},
],
})
export class OAuthModule {
}
```
```ts
export interface OAuthSignInDto {
provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/;
providerId: string;
providerUsername: string;
email: string;
}
```
### 4. Add frontend icon
Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx).
```tsx
const getOAuthIcon = (provider: string) => {
return {
'github': <SiGithub />,
/* your provider */
}[provider];
}
```
### 5. Add i18n text
Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts).
- `signIn.oauth.YOUR_PROVIDER_NAME`
- `account.card.oauth.YOUR_PROVIDER_NAME`
- `admin.config.oauth.YOUR_PROVIDER_NAME-enabled`
- `admin.config.oauth.YOUR_PROVIDER_NAME-client-id`
- `admin.config.oauth.YOUR_PROVIDER_NAME-client-secret`
- `error.param.provider_YOUR_PROVIDER_NAME`
- Other config keys you defined in step 1
Congratulations! 🎉 You have successfully added a new OAuth 2 provider! Pull requests are welcome if you want to share your provider with others.

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-frontend",
"version": "0.18.2",
"version": "0.29.0",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -9,45 +9,46 @@
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@mantine/core": "^6.0.17",
"@mantine/dropzone": "^6.0.17",
"@mantine/form": "^6.0.17",
"@mantine/hooks": "^6.0.17",
"@mantine/modals": "^6.0.17",
"@mantine/next": "^6.0.17",
"@mantine/notifications": "^6.0.17",
"axios": "^1.4.0",
"@mantine/core": "^6.0.21",
"@mantine/dropzone": "^6.0.21",
"@mantine/form": "^6.0.21",
"@mantine/hooks": "^6.0.21",
"@mantine/modals": "^6.0.21",
"@mantine/next": "^6.0.21",
"@mantine/notifications": "^6.0.21",
"axios": "^1.7.2",
"cookies-next": "^2.1.2",
"file-saver": "^2.0.5",
"jose": "^4.14.4",
"jose": "^4.15.5",
"jwt-decode": "^3.1.2",
"markdown-to-jsx": "^7.4.7",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.4.12",
"next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5",
"moment": "^2.30.1",
"next": "^14.2.3",
"next-http-proxy-middleware": "^1.2.6",
"next-pwa": "^5.6.0",
"p-limit": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.10.1",
"react-intl": "^6.4.4",
"sharp": "^0.32.4",
"yup": "^1.2.0"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^4.12.0",
"react-intl": "^6.6.8",
"sharp": "^0.33.4",
"yup": "^1.4.0"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"@types/node": "20.4.5",
"@types/react": "18.2.17",
"@types/react-dom": "18.2.7",
"axios": "^1.4.0",
"eslint": "8.46.0",
"eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^8.9.0",
"prettier": "^3.0.0",
"tar": "^6.1.15",
"typescript": "^5.1.6"
"@types/mime-types": "^2.1.4",
"@types/node": "20.12.12",
"@types/react": "18.3.2",
"@types/react-dom": "18.3.0",
"@typescript-eslint/parser": "^7.10.0",
"axios": "^1.7.2",
"eslint": "8.57.0",
"eslint-config-next": "^13.5.6",
"eslint-config-prettier": "^8.10.0",
"prettier": "^3.2.5",
"tar": "^6.2.1",
"typescript": "^5.4.5"
}
}

View File

@@ -83,7 +83,7 @@ const CreateEnableTotpModal = ({
</span>
</Center>
<Tooltip label={t("account.modal.totp.clickToCopy")}>
<Tooltip label={t("common.button.clickToCopy")}>
<Button
onClick={() => {
navigator.clipboard.writeText(options.secret);

View File

@@ -17,13 +17,9 @@ const showShareInformationsModal = (
const t = translateOutsideContext();
const link = `${appUrl}/s/${share.id}`;
let shareSize: number = 0;
for (let file of share.files as FileMetaData[])
shareSize += parseInt(file.size);
const formattedShareSize = byteToHumanSizeString(shareSize);
const formattedShareSize = byteToHumanSizeString(share.size);
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
const shareSizeProgress = (shareSize / maxShareSize) * 100;
const shareSizeProgress = (share.size / maxShareSize) * 100;
const formattedCreatedAt = moment(share.createdAt).format("LLL");
const formattedExpiration =
@@ -36,28 +32,34 @@ const showShareInformationsModal = (
children: (
<Stack align="stretch" spacing="md">
<Text size="sm" color="lightgray">
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.id" />:{" "}
</b>
{share.id}
</Text>
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.name" />:{" "}
</b>
{share.name || "-"}
</Text>
<Text size="sm" color="lightgray">
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.description" />:{" "}
</b>
{share.description || "No description"}
{share.description || "-"}
</Text>
<Text size="sm" color="lightgray">
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
</b>
{formattedCreatedAt}
</Text>
<Text size="sm" color="lightgray">
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.expiresAt" />:{" "}
</b>
@@ -66,7 +68,7 @@ const showShareInformationsModal = (
<Divider />
<CopyTextField link={link} />
<Divider />
<Text size="sm" color="lightgray">
<Text size="sm">
<b>
<FormattedMessage id="account.shares.table.size" />:{" "}
</b>
@@ -75,19 +77,19 @@ const showShareInformationsModal = (
</Text>
<Flex align="center" justify="center">
{shareSize / maxShareSize < 0.1 && (
<Text size="xs" color="lightgray" style={{ marginRight: "4px" }}>
{share.size / maxShareSize < 0.1 && (
<Text size="xs" style={{ marginRight: "4px" }}>
{formattedShareSize}
</Text>
)}
<Progress
value={shareSizeProgress}
label={shareSize / maxShareSize >= 0.1 ? formattedShareSize : ""}
style={{ width: shareSize / maxShareSize < 0.1 ? "70%" : "80%" }}
label={share.size / maxShareSize >= 0.1 ? formattedShareSize : ""}
style={{ width: share.size / maxShareSize < 0.1 ? "70%" : "80%" }}
size="xl"
radius="xl"
/>
<Text size="xs" color="lightgray" style={{ marginLeft: "4px" }}>
<Text size="xs" style={{ marginLeft: "4px" }}>
{formattedMaxShareSize}
</Text>
</Flex>

View File

@@ -11,7 +11,7 @@ import {
} from "@mantine/core";
import Link from "next/link";
import { Dispatch, SetStateAction } from "react";
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
const categories = [
@@ -19,6 +19,7 @@ const categories = [
{ name: "Email", icon: <TbMail /> },
{ name: "Share", icon: <TbShare /> },
{ name: "SMTP", icon: <TbAt /> },
{ name: "OAuth", icon: <TbSocial /> },
];
const useStyles = createStyles((theme) => ({

View File

@@ -33,6 +33,7 @@ const LogoConfigInput = ({
value={logo}
onChange={(v) => setLogo(v)}
accept=".png"
// @ts-ignore (https://github.com/mantinedev/mantine/issues/5401)
placeholder={t("admin.config.general.logo.placeholder")}
/>
</Box>

View 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;

View File

@@ -2,9 +2,11 @@ import {
Anchor,
Button,
Container,
createStyles,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
@@ -18,19 +20,61 @@ import { TbInfoCircle } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import * as yup from "yup";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import useUser from "../../hooks/user.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import authService from "../../services/auth.service";
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
import toast from "../../utils/toast.util";
import { safeRedirectPath } from "../../utils/router.util";
const useStyles = createStyles((theme) => ({
signInWith: {
fontWeight: 500,
"&:before": {
content: "''",
flex: 1,
display: "block",
},
"&:after": {
content: "''",
flex: 1,
display: "block",
},
},
or: {
"&:before": {
content: "''",
flex: 1,
display: "block",
borderTopWidth: 1,
borderTopStyle: "solid",
borderColor:
theme.colorScheme === "dark"
? theme.colors.dark[3]
: theme.colors.gray[4],
},
"&:after": {
content: "''",
flex: 1,
display: "block",
borderTopWidth: 1,
borderTopStyle: "solid",
borderColor:
theme.colorScheme === "dark"
? theme.colors.dark[3]
: theme.colors.gray[4],
},
},
}));
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
const config = useConfig();
const router = useRouter();
const t = useTranslate();
const { refreshUser } = useUser();
const { classes } = useStyles();
const [showTotp, setShowTotp] = React.useState(false);
const [loginToken, setLoginToken] = React.useState("");
const [oauth, setOAuth] = React.useState<string[]>([]);
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(t("common.error.field-required")),
@@ -44,7 +88,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
initialValues: {
emailOrUsername: "",
password: "",
totp: "",
},
validate: yupResolver(validationSchema),
});
@@ -55,7 +98,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
.then(async (response) => {
if (response.data["loginToken"]) {
// Prompt the user to enter their totp code
setShowTotp(true);
showNotification({
icon: <TbInfoCircle />,
color: "blue",
@@ -63,34 +105,28 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
title: t("signIn.notify.totp-required.title"),
message: t("signIn.notify.totp-required.description"),
});
setLoginToken(response.data["loginToken"]);
router.push(
`/auth/totp/${
response.data["loginToken"]
}?redirect=${encodeURIComponent(redirectPath)}`,
);
} else {
await refreshUser();
router.replace(redirectPath);
router.replace(safeRedirectPath(redirectPath));
}
})
.catch(toast.axiosError);
};
const signInTotp = (email: string, password: string, totp: string) => {
authService
.signInTotp(email, password, totp, loginToken)
.then(async () => {
await refreshUser();
router.replace(redirectPath);
})
.catch((error) => {
if (error?.response?.data?.error == "share_password_required") {
toast.axiosError(error);
// Refresh the page to start over
window.location.reload();
}
toast.axiosError(error);
form.setValues({ totp: "" });
});
const getAvailableOAuth = async () => {
const oauth = await authService.getAvailableOAuth();
setOAuth(oauth.data);
};
React.useEffect(() => {
getAvailableOAuth().catch(toast.axiosError);
}, []);
return (
<Container size={420} my={40}>
<Title order={2} align="center" weight={900}>
@@ -105,44 +141,63 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) => {
if (showTotp)
signInTotp(values.emailOrUsername, values.password, values.totp);
else signIn(values.emailOrUsername, values.password);
})}
>
<TextInput
label={t("signin.input.email-or-username")}
placeholder={t("signin.input.email-or-username.placeholder")}
{...form.getInputProps("emailOrUsername")}
/>
<PasswordInput
label={t("signin.input.password")}
placeholder={t("signin.input.password.placeholder")}
mt="md"
{...form.getInputProps("password")}
/>
{showTotp && (
{config.get("oauth.disablePassword") || (
<form
onSubmit={form.onSubmit((values) => {
signIn(values.emailOrUsername, values.password);
})}
>
<TextInput
variant="filled"
label={t("account.modal.totp.code")}
placeholder="******"
mt="md"
{...form.getInputProps("totp")}
label={t("signin.input.email-or-username")}
placeholder={t("signin.input.email-or-username.placeholder")}
{...form.getInputProps("emailOrUsername")}
/>
)}
{config.get("smtp.enabled") && (
<Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs">
<FormattedMessage id="resetPassword.title" />
</Anchor>
<PasswordInput
label={t("signin.input.password")}
placeholder={t("signin.input.password.placeholder")}
mt="md"
{...form.getInputProps("password")}
/>
{config.get("smtp.enabled") && (
<Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs">
<FormattedMessage id="resetPassword.title" />
</Anchor>
</Group>
)}
<Button fullWidth mt="xl" type="submit">
<FormattedMessage id="signin.button.submit" />
</Button>
</form>
)}
{oauth.length > 0 && (
<Stack mt={config.get("oauth.disablePassword") ? undefined : "xl"}>
{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">
{oauth.map((provider) => (
<Button
key={provider}
component="a"
title={t(`signIn.oauth.${provider}`)}
href={getOAuthUrl(config.get("general.appUrl"), provider)}
variant="light"
fullWidth
>
{getOAuthIcon(provider)}
{"\u2002" + t(`signIn.oauth.${provider}`)}
</Button>
))}
</Group>
)}
<Button fullWidth mt="xl" type="submit">
<FormattedMessage id="signin.button.submit" />
</Button>
</form>
</Stack>
)}
</Paper>
</Container>
);

View File

@@ -0,0 +1,85 @@
import {
Button,
Container,
Group,
Paper,
PinInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useRouter } from "next/router";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
import * as yup from "yup";
import useTranslate from "../../hooks/useTranslate.hook";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import { safeRedirectPath } from "../../utils/router.util";
import toast from "../../utils/toast.util";
function TotpForm({ redirectPath }: { redirectPath: string }) {
const t = useTranslate();
const router = useRouter();
const { refreshUser } = useUser();
const [loading, setLoading] = useState(false);
const validationSchema = yup.object().shape({
code: yup
.string()
.min(6, t("common.error.too-short", { length: 6 }))
.required(t("common.error.field-required")),
});
const form = useForm({
initialValues: {
code: "",
},
validate: yupResolver(validationSchema),
});
const onSubmit = async () => {
if (loading) return;
setLoading(true);
try {
await authService.signInTotp(
form.values.code,
router.query.loginToken as string,
);
await refreshUser();
await router.replace(safeRedirectPath(redirectPath));
} catch (e) {
toast.axiosError(e);
form.setFieldError("code", "error");
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={40}>
<Title order={2} align="center" weight={900}>
<FormattedMessage id="totp.title" />
</Title>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(onSubmit)}>
<Group position="center">
<PinInput
length={6}
oneTimeCode
aria-label="One time code"
autoFocus={true}
onComplete={onSubmit}
{...form.getInputProps("code")}
/>
<Button mt="md" type="submit" loading={loading}>
{t("totp.button.signIn")}
</Button>
</Group>
</form>
</Paper>
</Container>
);
}
export default TotpForm;

View File

@@ -39,7 +39,7 @@ const FileList = ({
const t = useTranslate();
const [sort, setSort] = useState<TableSort>({
property: undefined,
property: "name",
direction: "desc",
});

View File

@@ -1,9 +1,17 @@
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import {
Button,
Center,
Stack,
Text,
Title,
useMantineTheme,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import Link from "next/link";
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import api from "../../services/api.service";
import Markdown from "markdown-to-jsx";
const FilePreviewContext = React.createContext<{
shareId: string;
@@ -115,23 +123,38 @@ const ImagePreview = () => {
const TextPreview = () => {
const { shareId, fileId } = React.useContext(FilePreviewContext);
const [text, setText] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const { colorScheme } = useMantineTheme();
useEffect(() => {
api
.get(`/shares/${shareId}/files/${fileId}?download=false`)
.then((res) => setText(res.data));
.then((res) => setText(res.data ?? "Preview couldn't be fetched."));
}, [shareId, fileId]);
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<Text sx={{ whiteSpace: "pre-wrap" }} size="sm">
{text}
</Text>
</Stack>
</Center>
);
const options = {
overrides: {
pre: {
props: {
style: {
backgroundColor:
colorScheme == "dark"
? "rgba(50, 50, 50, 0.5)"
: "rgba(220, 220, 220, 0.5)",
padding: "0.75em",
whiteSpace: "pre-wrap",
},
},
},
table: {
props: {
className: "md",
},
},
},
};
return <Markdown options={options}>{text}</Markdown>;
};
const PdfPreview = () => {

View File

@@ -12,6 +12,7 @@ import {
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
@@ -21,10 +22,12 @@ import { getExpirationPreview } from "../../../utils/date.util";
import toast from "../../../utils/toast.util";
import FileSizeInput from "../FileSizeInput";
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
import { getCookie, setCookie } from "cookies-next";
const showCreateReverseShareModal = (
modals: ModalsContextProps,
showSendEmailNotificationOption: boolean,
maxExpirationInHours: number,
getReverseShares: () => void,
) => {
const t = translateOutsideContext();
@@ -34,6 +37,7 @@ const showCreateReverseShareModal = (
<Body
showSendEmailNotificationOption={showSendEmailNotificationOption}
getReverseShares={getReverseShares}
maxExpirationInHours={maxExpirationInHours}
/>
),
});
@@ -42,9 +46,11 @@ const showCreateReverseShareModal = (
const Body = ({
getReverseShares,
showSendEmailNotificationOption,
maxExpirationInHours,
}: {
getReverseShares: () => void;
showSendEmailNotificationOption: boolean;
maxExpirationInHours: number;
}) => {
const modals = useModals();
const t = useTranslate();
@@ -56,29 +62,58 @@ const Body = ({
sendEmailNotification: false,
expiration_num: 1,
expiration_unit: "-days",
simplified: !!(getCookie("reverse-share.simplified") ?? false),
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
},
});
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(
form.values.expiration_num,
form.values.expiration_unit.replace(
"-",
"",
) as moment.unitOfTime.DurationConstructor,
);
if (
maxExpirationInHours != 0 &&
expirationDate.isAfter(moment().add(maxExpirationInHours, "hours"))
) {
form.setFieldError(
"expiration_num",
t("upload.modal.expires.error.too-long", {
max: moment.duration(maxExpirationInHours, "hours").humanize(),
}),
);
return;
}
shareService
.createReverseShare(
values.expiration_num + values.expiration_unit,
values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification,
values.simplified,
values.publicAccess,
)
.then(({ link }) => {
modals.closeAll();
showCompletedReverseShareModal(modals, link, getReverseShares);
})
.catch(toast.axiosError);
});
return (
<Group>
<form
onSubmit={form.onSubmit(async (values) => {
shareService
.createReverseShare(
values.expiration_num + values.expiration_unit,
values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification,
)
.then(({ link }) => {
modals.closeAll();
showCompletedReverseShareModal(modals, link, getReverseShares);
})
.catch(toast.axiosError);
})}
>
<form onSubmit={onSubmit}>
<Stack align="stretch">
<div>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Grid align={form.errors.expiration_num ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
@@ -184,7 +219,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">
<FormattedMessage id="common.button.create" />
</Button>

View File

@@ -8,6 +8,7 @@ const showErrorModal = (
modals: ModalsContextProps,
title: string,
text: string,
action: "go-back" | "go-home" = "go-back",
) => {
return modals.openModal({
closeOnClickOutside: false,
@@ -15,11 +16,17 @@ const showErrorModal = (
closeOnEscape: false,
title: title,
children: <Body text={text} />,
children: <Body text={text} action={action} />,
});
};
const Body = ({ text }: { text: string }) => {
const Body = ({
text,
action,
}: {
text: string;
action: "go-back" | "go-home";
}) => {
const modals = useModals();
const router = useRouter();
return (
@@ -29,10 +36,14 @@ const Body = ({ text }: { text: string }) => {
<Button
onClick={() => {
modals.closeAll();
router.back();
if (action === "go-back") {
router.back();
} else if (action === "go-home") {
router.push("/");
}
}}
>
<FormattedMessage id="common.button.go-back" />
<FormattedMessage id={`common.button.${action}`} />
</Button>
</Stack>
</>

View File

@@ -1,7 +1,8 @@
import { ActionIcon, TextInput } from "@mantine/core";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useRef, useState } from "react";
import { TbCheck, TbCopy } from "react-icons/tb";
import { IoOpenOutline } from "react-icons/io5";
import useTranslate from "../../hooks/useTranslate.hook";
import toast from "../../utils/toast.util";
@@ -37,12 +38,35 @@ function CopyTextField(props: { link: string }) {
setTextClicked(true);
}
}}
rightSectionWidth={62}
rightSection={
window.isSecureContext && (
<ActionIcon onClick={copyLink}>
{checkState ? <TbCheck /> : <TbCopy />}
</ActionIcon>
)
<>
<Tooltip
label={t("common.text.navigate-to-link")}
position="top"
offset={-2}
openDelay={200}
>
<a href={props.link}>
<ActionIcon>
<IoOpenOutline />
</ActionIcon>
</a>
</Tooltip>
{window.isSecureContext && (
<Tooltip
label={t("common.button.clickToCopy")}
position="top"
offset={-2}
openDelay={200}
>
<ActionIcon onClick={copyLink}>
{checkState ? <TbCheck /> : <TbCopy />}
</ActionIcon>
</Tooltip>
)}
</>
}
/>
);

View File

@@ -33,13 +33,15 @@ const useStyles = createStyles((theme) => ({
}));
const Dropzone = ({
title,
isUploading,
maxShareSize,
showCreateUploadModalCallback,
onFilesChanged,
}: {
title?: string;
isUploading: boolean;
maxShareSize: number;
showCreateUploadModalCallback: (files: FileUpload[]) => void;
onFilesChanged: (files: FileUpload[]) => void;
}) => {
const t = useTranslate();
@@ -60,14 +62,14 @@ const Dropzone = ({
toast.error(
t("upload.dropzone.notify.file-too-big", {
maxSize: byteToHumanSizeString(maxShareSize),
})
}),
);
} else {
files = files.map((newFile) => {
newFile.uploadingProgress = 0;
return newFile;
});
showCreateUploadModalCallback(files);
onFilesChanged(files);
}
}}
className={classes.dropzone}
@@ -78,7 +80,7 @@ const Dropzone = ({
<TbCloudUpload size={50} />
</Group>
<Text align="center" weight={700} size="lg" mt="xl">
<FormattedMessage id="upload.dropzone.title" />
{title || <FormattedMessage id="upload.dropzone.title" />}
</Text>
<Text align="center" size="sm" mt="xs" color="dimmed">
<FormattedMessage

View File

@@ -0,0 +1,229 @@
import { Button, Group } from "@mantine/core";
import { cleanNotifications } from "@mantine/notifications";
import { AxiosError } from "axios";
import { useRouter } from "next/router";
import pLimit from "p-limit";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormattedMessage } from "react-intl";
import Dropzone from "../../components/upload/Dropzone";
import FileList from "../../components/upload/FileList";
import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service";
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
import toast from "../../utils/toast.util";
const promiseLimit = pLimit(3);
let errorToastShown = false;
const EditableUpload = ({
maxShareSize,
shareId,
files: savedFiles = [],
}: {
maxShareSize?: number;
isReverseShare?: boolean;
shareId: string;
files?: FileMetaData[];
}) => {
const t = useTranslate();
const router = useRouter();
const config = useConfig();
const chunkSize = useRef(parseInt(config.get("share.chunkSize")));
const [existingFiles, setExistingFiles] =
useState<Array<FileMetaData & { deleted?: boolean }>>(savedFiles);
const [uploadingFiles, setUploadingFiles] = useState<FileUpload[]>([]);
const [isUploading, setIsUploading] = useState(false);
const existingAndUploadedFiles: FileListItem[] = useMemo(
() => [...uploadingFiles, ...existingFiles],
[existingFiles, uploadingFiles],
);
const dirty = useMemo(() => {
return (
existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length
);
}, [existingFiles, uploadingFiles]);
const setFiles = (files: FileListItem[]) => {
const _uploadFiles = files.filter(
(file) => "uploadingProgress" in file,
) as FileUpload[];
const _existingFiles = files.filter(
(file) => !("uploadingProgress" in file),
) as FileMetaData[];
setUploadingFiles(_uploadFiles);
setExistingFiles(_existingFiles);
};
maxShareSize ??= parseInt(config.get("share.maxSize"));
const uploadFiles = async (files: FileUpload[]) => {
const fileUploadPromises = files.map(async (file, fileIndex) =>
// Limit the number of concurrent uploads to 3
promiseLimit(async () => {
let fileId: string | undefined;
const setFileProgress = (progress: number) => {
setUploadingFiles((files) =>
files.map((file, callbackIndex) => {
if (fileIndex == callbackIndex) {
file.uploadingProgress = progress;
}
return file;
}),
);
};
setFileProgress(1);
let chunks = Math.ceil(file.size / chunkSize.current);
// If the file is 0 bytes, we still need to upload 1 chunk
if (chunks == 0) chunks++;
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
const from = chunkIndex * chunkSize.current;
const to = from + chunkSize.current;
const blob = file.slice(from, to);
try {
await shareService
.uploadFile(
shareId,
blob,
{
id: fileId,
name: file.name,
},
chunkIndex,
chunks,
)
.then((response) => {
fileId = response.id;
});
setFileProgress(((chunkIndex + 1) / chunks) * 100);
} catch (e) {
if (
e instanceof AxiosError &&
e.response?.data.error == "unexpected_chunk_index"
) {
// Retry with the expected chunk index
chunkIndex = e.response!.data!.expectedChunkIndex - 1;
continue;
} else {
setFileProgress(-1);
// Retry after 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));
chunkIndex = -1;
continue;
}
}
}
}),
);
await Promise.all(fileUploadPromises);
};
const removeFiles = async () => {
const removedFiles = existingFiles.filter((file) => !!file.deleted);
if (removedFiles.length > 0) {
await Promise.all(
removedFiles.map(async (file) => {
await shareService.removeFile(shareId, file.id);
}),
);
setExistingFiles(existingFiles.filter((file) => !file.deleted));
}
};
const revertComplete = async () => {
await shareService.revertComplete(shareId).then();
};
const completeShare = async () => {
return await shareService.completeShare(shareId);
};
const save = async () => {
setIsUploading(true);
try {
await revertComplete();
await uploadFiles(uploadingFiles);
const hasFailed = uploadingFiles.some(
(file) => file.uploadingProgress == -1,
);
if (!hasFailed) {
await removeFiles();
}
await completeShare();
if (!hasFailed) {
toast.success(t("share.edit.notify.save-success"));
router.back();
}
} catch {
toast.error(t("share.edit.notify.generic-error"));
} finally {
setIsUploading(false);
}
};
const appendFiles = (appendingFiles: FileUpload[]) => {
setUploadingFiles([...appendingFiles, ...uploadingFiles]);
};
useEffect(() => {
// Check if there are any files that failed to upload
const fileErrorCount = uploadingFiles.filter(
(file) => file.uploadingProgress == -1,
).length;
if (fileErrorCount > 0) {
if (!errorToastShown) {
toast.error(
t("upload.notify.count-failed", { count: fileErrorCount }),
{
withCloseButton: false,
autoClose: false,
},
);
}
errorToastShown = true;
} else {
cleanNotifications();
errorToastShown = false;
}
}, [uploadingFiles]);
return (
<>
<Group position="right" mb={20}>
<Button loading={isUploading} disabled={!dirty} onClick={() => save()}>
<FormattedMessage id="common.button.save" />
</Button>
</Group>
<Dropzone
title={t("share.edit.append-upload")}
maxShareSize={maxShareSize}
onFilesChanged={appendFiles}
isUploading={isUploading}
/>
{existingAndUploadedFiles.length > 0 && (
<FileList files={existingAndUploadedFiles} setFiles={setFiles} />
)}
</>
);
};
export default EditableUpload;

View File

@@ -1,41 +1,106 @@
import { ActionIcon, Table } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
import { TbTrash } from "react-icons/tb";
import { FileUpload } from "../../types/File.type";
import { GrUndo } from "react-icons/gr";
import { FileListItem } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import UploadProgressIndicator from "./UploadProgressIndicator";
import { FormattedMessage } from "react-intl";
const FileList = ({
const FileListRow = ({
file,
onRemove,
onRestore,
}: {
file: FileListItem;
onRemove?: () => void;
onRestore?: () => void;
}) => {
{
const uploadable = "uploadingProgress" in file;
const uploading = uploadable && file.uploadingProgress !== 0;
const removable = uploadable
? file.uploadingProgress === 0
: onRemove && !file.deleted;
const restorable = onRestore && !uploadable && !!file.deleted; // maybe undefined, force boolean
const deleted = !uploadable && !!file.deleted;
return (
<tr
style={{
color: deleted ? "rgba(120, 120, 120, 0.5)" : "inherit",
textDecoration: deleted ? "line-through" : "none",
}}
>
<td>{file.name}</td>
<td>{byteToHumanSizeString(+file.size)}</td>
<td>
{removable && (
<ActionIcon
color="red"
variant="light"
size={25}
onClick={onRemove}
>
<TbTrash />
</ActionIcon>
)}
{uploading && (
<UploadProgressIndicator progress={file.uploadingProgress} />
)}
{restorable && (
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={onRestore}
>
<GrUndo />
</ActionIcon>
)}
</td>
</tr>
);
}
};
const FileList = <T extends FileListItem = FileListItem>({
files,
setFiles,
}: {
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
files: T[];
setFiles: (files: T[]) => void;
}) => {
const remove = (index: number) => {
files.splice(index, 1);
const file = files[index];
if ("uploadingProgress" in file) {
files.splice(index, 1);
} else {
files[index] = { ...file, deleted: true };
}
setFiles([...files]);
};
const restore = (index: number) => {
const file = files[index];
if ("uploadingProgress" in file) {
return;
} else {
files[index] = { ...file, deleted: false };
}
setFiles([...files]);
};
const rows = files.map((file, i) => (
<tr key={i}>
<td>{file.name}</td>
<td>{byteToHumanSizeString(file.size)}</td>
<td>
{file.uploadingProgress == 0 ? (
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => remove(i)}
>
<TbTrash />
</ActionIcon>
) : (
<UploadProgressIndicator progress={file.uploadingProgress} />
)}
</td>
</tr>
<FileListRow
key={i}
file={file}
onRemove={() => remove(i)}
onRestore={() => restore(i)}
/>
));
return (

View File

@@ -7,12 +7,12 @@ import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import { Share } from "../../../types/share.type";
import { CompletedShare } from "../../../types/share.type";
import CopyTextField from "../CopyTextField";
const showCompletedUploadModal = (
modals: ModalsContextProps,
share: Share,
share: CompletedShare,
appUrl: string,
) => {
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 router = useRouter();
const t = useTranslate();
@@ -35,6 +35,19 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
return (
<Stack align="stretch">
<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
size="xs"
sx={(theme) => ({

View File

@@ -18,6 +18,7 @@ import {
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import { useState } from "react";
import { TbAlertCircle } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
@@ -29,6 +30,8 @@ import shareService from "../../../services/share.service";
import { FileUpload } from "../../../types/File.type";
import { CreateShare } from "../../../types/share.type";
import { getExpirationPreview } from "../../../utils/date.util";
import React from "react";
import toast from "../../../utils/toast.util";
const showCreateUploadModal = (
modals: ModalsContextProps,
@@ -38,12 +41,27 @@ const showCreateUploadModal = (
appUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
maxExpirationInHours: number;
simplified: boolean;
},
files: FileUpload[],
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
) => {
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({
title: t("upload.modal.title"),
children: (
@@ -56,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 = ({
uploadCallback,
files,
@@ -69,14 +104,13 @@ const CreateUploadModalBody = ({
appUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
maxExpirationInHours: number;
};
}) => {
const modals = useModals();
const t = useTranslate();
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7);
const generatedLink = generateLink();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
@@ -89,11 +123,25 @@ const CreateUploadModalBody = ({
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: t("upload.modal.link.error.invalid"),
}),
password: yup.string().min(3).max(30),
maxViews: yup.number().min(1),
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
.string()
.transform((value) => value || undefined)
.min(3, t("common.error.too-short", { length: 3 }))
.max(30, t("common.error.too-long", { length: 30 })),
maxViews: yup
.number()
.transform((value) => value || undefined)
.min(1),
});
const form = useForm({
initialValues: {
name: undefined,
link: generatedLink,
recipients: [] as string[],
password: undefined,
@@ -105,6 +153,59 @@ const CreateUploadModalBody = ({
},
validate: yupResolver(validationSchema),
});
const onSubmit = form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", t("upload.modal.link.error.taken"));
} else {
const expirationString = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
const expirationDate = moment().add(
form.values.expiration_num,
form.values.expiration_unit.replace(
"-",
"",
) as moment.unitOfTime.DurationConstructor,
);
if (
options.maxExpirationInHours != 0 &&
(form.values.never_expires ||
expirationDate.isAfter(
moment().add(options.maxExpirationInHours, "hours"),
))
) {
form.setFieldError(
"expiration_num",
t("upload.modal.expires.error.too-long", {
max: moment
.duration(options.maxExpirationInHours, "hours")
.humanize(),
}),
);
return;
}
uploadCallback(
{
id: values.link,
name: values.name,
expiration: expirationString,
recipients: values.recipients,
description: values.description,
security: {
password: values.password || undefined,
maxViews: values.maxViews || undefined,
},
},
files,
);
modals.closeAll();
}
});
return (
<>
{showNotSignedInAlert && !options.isUserSignedIn && (
@@ -118,33 +219,9 @@ const CreateUploadModalBody = ({
<FormattedMessage id="upload.modal.not-signed-in-description" />
</Alert>
)}
<form
onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", t("upload.modal.link.error.taken"));
} else {
const expiration = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback(
{
id: values.link,
expiration: expiration,
recipients: values.recipients,
description: values.description,
security: {
password: values.password,
maxViews: values.maxViews,
},
},
files
);
modals.closeAll();
}
})}
>
<form onSubmit={onSubmit}>
<Stack align="stretch">
<Group align="end">
<Group align={form.errors.link ? "center" : "flex-end"}>
<TextInput
style={{ flex: "1" }}
variant="filled"
@@ -155,14 +232,7 @@ const CreateUploadModalBody = ({
<Button
style={{ flex: "0 0 auto" }}
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
onClick={() => form.setFieldValue("link", generateLink())}
>
<FormattedMessage id="common.button.generate" />
</Button>
@@ -179,7 +249,7 @@ const CreateUploadModalBody = ({
</Text>
{!options.isReverseShare && (
<>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Grid align={form.errors.expiration_num ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
@@ -196,7 +266,6 @@ const CreateUploadModalBody = ({
disabled={form.values.never_expires}
{...form.getInputProps("expiration_unit")}
data={[
// Set the label to singular if the number is 1, else plural
{
value: "-minutes",
label:
@@ -243,10 +312,12 @@ const CreateUploadModalBody = ({
/>
</Col>
</Grid>
<Checkbox
label={t("upload.modal.expires.never-long")}
{...form.getInputProps("never_expires")}
/>
{options.maxExpirationInHours == 0 && (
<Checkbox
label={t("upload.modal.expires.never-long")}
{...form.getInputProps("never_expires")}
/>
)}
<Text
italic
size="xs"
@@ -259,7 +330,7 @@ const CreateUploadModalBody = ({
neverExpires: t("upload.modal.completed.never-expires"),
expiresOn: t("upload.modal.completed.expires-on"),
},
form
form,
)}
</Text>
</>
@@ -267,14 +338,21 @@ const CreateUploadModalBody = ({
<Accordion>
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
<Accordion.Control>
<FormattedMessage id="upload.modal.accordion.description.title" />
<FormattedMessage id="upload.modal.accordion.name-and-description.title" />
</Accordion.Control>
<Accordion.Panel>
<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.description.placeholder"
"upload.modal.accordion.name-and-description.description.placeholder",
)}
{...form.getInputProps("description")}
/>
@@ -292,13 +370,15 @@ const CreateUploadModalBody = ({
placeholder={t("upload.modal.accordion.email.placeholder")}
searchable
creatable
autoComplete="email-recipients"
id="recipient_email"
autoComplete="email"
type="email"
getCreateLabel={(query) => `+ ${query}`}
onCreate={(query) => {
if (!query.match(/^\S+@\S+\.\S+$/)) {
form.setFieldError(
"recipients",
t("upload.modal.accordion.email.invalid-email")
t("upload.modal.accordion.email.invalid-email"),
);
} else {
form.setFieldError("recipients", null);
@@ -310,6 +390,25 @@ const CreateUploadModalBody = ({
}
}}
{...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.Item>
@@ -324,7 +423,7 @@ const CreateUploadModalBody = ({
<PasswordInput
variant="filled"
placeholder={t(
"upload.modal.accordion.security.password.placeholder"
"upload.modal.accordion.security.password.placeholder",
)}
label={t("upload.modal.accordion.security.password.label")}
autoComplete="off"
@@ -335,7 +434,7 @@ const CreateUploadModalBody = ({
type="number"
variant="filled"
placeholder={t(
"upload.modal.accordion.security.max-views.placeholder"
"upload.modal.accordion.security.max-views.placeholder",
)}
label={t("upload.modal.accordion.security.max-views.label")}
{...form.getInputProps("maxViews")}
@@ -353,4 +452,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;

View File

@@ -1,16 +1,27 @@
import arabic from "./translations/ar-EG";
import danish from "./translations/da-DK";
import german from "./translations/de-DE";
import greek from "./translations/el-GR";
import english from "./translations/en-US";
import spanish from "./translations/es-ES";
import finnish from "./translations/fi-FI";
import french from "./translations/fr-FR";
import hungarian from "./translations/hu-HU";
import italian from "./translations/it-IT";
import japanese from "./translations/ja-JP";
import korean from "./translations/ko-KR";
import dutch from "./translations/nl-BE";
import polish from "./translations/pl-PL";
import portuguese from "./translations/pt-BR";
import russian from "./translations/ru-RU";
import slovenian from "./translations/sl-SI";
import serbian from "./translations/sr-SP";
import swedish from "./translations/sv-SE";
import thai from "./translations/th-TH";
import ukrainian from "./translations/uk-UA";
import chineseSimplified from "./translations/zh-CN";
import chineseTraditional from "./translations/zh-TW";
import turkish from "./translations/tr-TR";
export const LOCALES = {
ENGLISH: {
@@ -48,6 +59,11 @@ export const LOCALES = {
code: "zh-CN",
messages: chineseSimplified,
},
CHINESE_TRADITIONAL: {
name: "正體中文",
code: "zh-TW",
messages: chineseTraditional,
},
FINNISH: {
name: "Suomi",
code: "fi-FI",
@@ -58,6 +74,11 @@ export const LOCALES = {
code: "ru-RU",
messages: russian,
},
UKRAINIAN: {
name: "Українська",
code: "uk-UA",
messages: ukrainian,
},
THAI: {
name: "ไทย",
code: "th-TH",
@@ -78,4 +99,49 @@ export const LOCALES = {
code: "ja-JP",
messages: japanese,
},
POLISH: {
name: "Polski",
code: "pl-PL",
messages: polish,
},
SWEDISH: {
name: "Svenska",
code: "sv-SE",
messages: swedish,
},
ITALIAN: {
name: "Italiano",
code: "it-IT",
messages: italian,
},
GREEK: {
name: "Ελληνικά",
code: "el-GR",
messages: greek,
},
SLOVENIAN: {
name: "Slovenščina",
code: "sl-SI",
messages: slovenian,
},
ARABIC: {
name: "العربية",
code: "ar-EG",
messages: arabic,
},
HUNGARIAN: {
name: "Hungarian",
code: "hu-HU",
messages: hungarian,
},
KOREAN: {
name: "한국어",
code: "ko-KR",
messages: korean,
},
TURKISH: {
name: "Türkçe",
code: "tr-TR",
messages: turkish,
},
};

View File

@@ -0,0 +1,449 @@
export default {
// Navbar
"navbar.upload": "رفع",
"navbar.signin": "تسجيل الدخول",
"navbar.home": "الصفحة الرئيسية",
"navbar.signup": "إنشاء حساب",
"navbar.links.shares": "مشاركاتي",
"navbar.links.reverse": "مشاركاتي العكسية",
"navbar.avatar.account": "حسابي",
"navbar.avatar.admin": "الإدارة",
"navbar.avatar.signout": "تسجيل الخروج",
// END navbar
// /
"home.title": "منصة لمشاركة الملفات <h>باستضافة ذاتية</h>.",
"home.description": "أحقًا تريد تسليم ملفاتك الشخصية لطرف ثالث مثل WeTransfer؟",
"home.bullet.a.name": "استضافة ذاتية",
"home.bullet.a.description": "قم باستضافة Pingvin Share على جهازك.",
"home.bullet.b.name": "الخصوصية",
"home.bullet.b.description": "ملفاتك تخصّك وحدك فقط، ولا ينبغي أبدًا أن تقع بأيدي طرفٍ ثالث.",
"home.bullet.c.name": "ليس هناك أية قيود على حجم الملفات",
"home.bullet.c.description": "ارفع أي ملف تريده مهما كان حجمه كبيرًا. إن مساحة قرصك الصلب هي المحدد الوحيد هنا.",
"home.button.start": "ابدأ",
"home.button.source": "النص البرمجي المصدري",
// END /
// /auth/signin
"signin.title": "أهلًا بعودتك",
"signin.description": "ليس لديك حساب؟",
"signin.button.signup": "إنشاء حساب",
"signin.input.email-or-username": "البريد أو اسم المستخدم",
"signin.input.email-or-username.placeholder": "بريدك أو اسم المستخدم",
"signin.input.password": "كلمة السر",
"signin.input.password.placeholder": "كلمة السر",
"signin.button.submit": "تسجيل الدخول",
"signIn.notify.totp-required.title": "إن المصادقة الثنائية ضرورية",
"signIn.notify.totp-required.description": "فضلًا أدخل رمز المصادقة الثنائية",
"signIn.oauth.or": "أو",
"signIn.oauth.signInWith": "تسجيل الدخول بواسطة تطبيق",
"signIn.oauth.github": "GitHub",
"signIn.oauth.google": "Google",
"signIn.oauth.microsoft": "Microsoft",
"signIn.oauth.discord": "Discord",
"signIn.oauth.oidc": "OpenID",
// END /auth/signin
// /auth/signup
"signup.title": "أنشئ حسابًا",
"signup.description": "لديك حساب بالفعل؟",
"signup.button.signin": "تسجيل الدخول",
"signup.input.username": "اسم المستخدم",
"signup.input.username.placeholder": "اسم المستخدم",
"signup.input.email": "البريد",
"signup.input.email.placeholder": "بريدك",
"signup.button.submit": "لنبدأ",
// END /auth/signup
// /auth/totp
"totp.title": "كلمة المرور لمرة واحدة المؤقتة TOTP",
"totp.button.signIn": "تسجيل الدخول",
// END /auth/totp
// /auth/reset-password
"resetPassword.title": "نسيت كلمة سرّك؟",
"resetPassword.description": "اكتب بريدك لتعيد تعيين كلمة السر.",
"resetPassword.notify.success": "إذا كان هذا البريد مسجلًا لدينا فستصله الآن رسالة فيها رابط لإعادة تعيين كلمة السرّ.",
"resetPassword.button.back": "العودة لصفحة تسجيل الدخول",
"resetPassword.text.resetPassword": "إعادة تعيين كلمة السر",
"resetPassword.text.enterNewPassword": "أدخل كلمة السر الجديدة",
"resetPassword.input.password": "كلمة السر الجديدة",
"resetPassword.notify.passwordReset": "أعدتَ تعيين كلمة السر بنجاح.",
// /account
"account.title": "حسابي",
"account.card.info.title": "معلومات الحساب",
"account.card.info.username": "اسم المستخدم",
"account.card.info.email": "البريد",
"account.notify.info.success": "تم تحديث الحساب بنجاح",
"account.card.password.title": "كلمة السر",
"account.card.password.old": "كلمة السر القديمة",
"account.card.password.new": "كلمة السر الجديدة",
"account.card.password.noPasswordSet": "ليس لحسابك كلمة سر. إذا أردت تسجيل الدخول باستخدام البريد وكلمة سر، فعليك أن تُعيِّن كلمة سر.",
"account.notify.password.success": "غيرت كلمة السر بنجاح",
"account.card.oauth.title": "الدخول بحساب تواصل اجتماعي",
"account.card.oauth.github": "GitHub",
"account.card.oauth.google": "Google",
"account.card.oauth.microsoft": "Microsoft",
"account.card.oauth.discord": "Discord",
"account.card.oauth.oidc": "OpenID",
"account.card.oauth.link": "ربط",
"account.card.oauth.unlink": "فك الربط",
"account.card.oauth.unlinked": "تم فك الربط",
"account.modal.unlink.title": "فك ربط الحساب",
"account.modal.unlink.description": "قد يؤدي إلغاء ربط حساباتك الاجتماعية إلى فقدان وصولك لحسابك إذا كنت لا تتذكر اسم المستخدم وكلمة السر الخاصة بك.",
"account.notify.oauth.unlinked.success": "تم فك الربط بنجاح",
"account.card.security.title": "الأمان",
"account.card.security.totp.enable.description": "اكتب كلمة سرّك لبدء تمكين TOTP",
"account.card.security.totp.disable.description": "اكتب كلمة سرّك لتعطيل TOTP",
"account.card.security.totp.button.start": "ابدأ",
"account.modal.totp.title": "تمكين TOTP",
"account.modal.totp.step1": "الخطوة 1: أضف تطبيق المصادقة",
"account.modal.totp.step2": "الخطوة 2: تحقّق من صحة رمزك",
"account.modal.totp.enterManually": "أدخل يدوياً",
"account.modal.totp.code": "الرمز",
"common.button.clickToCopy": "انقر للنسخ",
"account.modal.totp.verify": "تحقق",
"account.notify.totp.disable": "تم تعطيل TOTP بنجاح",
"account.notify.totp.enable": "تم تمكين TOTP بنجاح",
"account.card.language.title": "اللغة",
"account.card.language.description": "يقوم المجتمع بترجمة هذا المشروع. ربما بعض اللغات لم تكتمل ترجمتها بعد.",
"account.card.color.title": "نظام الألوان",
// ThemeSwitcher.tsx
"account.theme.dark": "داكن",
"account.theme.light": "فاتح",
"account.theme.system": "حسب النظام",
"account.button.delete": "حذف الحساب",
"account.modal.delete.title": "حذف الحساب",
"account.modal.delete.description": "هل تريد حقاً حذف حسابك بما في ذلك جميع مشاركاتك النشطة؟",
// END /account
// /account/shares
"account.shares.title": "مشاركاتي",
"account.shares.title.empty": "المكان خالٍ هنا 👀",
"account.shares.description.empty": "ليس لديك أي مشاركات.",
"account.shares.button.create": "أنشئ واحدًا",
"account.shares.info.title": "معلومات المشاركة",
"account.shares.table.id": "الرقم التعريفي",
"account.shares.table.name": "الاسم",
"account.shares.table.description": "الوصف",
"account.shares.table.visitors": "الزوار",
"account.shares.table.expiresAt": "تاريخ انتهاء الصلاحية",
"account.shares.table.createdAt": "تاريخ الإنشاء",
"account.shares.table.size": "الحجم",
"account.shares.modal.share-informations": "معلومات المشاركة",
"account.shares.modal.share-link": "رابط المشاركة",
"account.shares.modal.delete.title": "حذف المشاركة {share}",
"account.shares.modal.delete.description": "هل تريد حذف هذه المشاركة حقاً؟",
// END /account/shares
// /account/reverseShares
"account.reverseShares.title": "المشاركات العكسية",
"account.reverseShares.description": "تسمح لك المشاركة العكسية بإنشاء رابط فريد يسمح للمستخدمين الخارجيين بإنشاء مشاركة.",
"account.reverseShares.title.empty": "المكان خالٍ هنا 👀",
"account.reverseShares.description.empty": "ليس لديك أي مشاركات عكسية.",
// showCreateReverseShareModal.tsx
"account.reverseShares.modal.title": "إنشاء مشاركة عكسية",
"account.reverseShares.modal.expiration.label": "انتهاء الصلاحية",
"account.reverseShares.modal.expiration.minute-singular": "دقيقة",
"account.reverseShares.modal.expiration.minute-plural": "دقائق",
"account.reverseShares.modal.expiration.hour-singular": "ساعة",
"account.reverseShares.modal.expiration.hour-plural": "ساعات",
"account.reverseShares.modal.expiration.day-singular": "يوم",
"account.reverseShares.modal.expiration.day-plural": "أيام",
"account.reverseShares.modal.expiration.week-singular": "أسبوع",
"account.reverseShares.modal.expiration.week-plural": "أسابيع",
"account.reverseShares.modal.expiration.month-singular": "شهر",
"account.reverseShares.modal.expiration.month-plural": "أشهر",
"account.reverseShares.modal.expiration.year-singular": "سنة",
"account.reverseShares.modal.expiration.year-plural": "سنوات",
"account.reverseShares.modal.max-size.label": "الحد الأقصى لحجم المشاركة",
"account.reverseShares.modal.send-email": "أرسل إشعارًا بالبريد",
"account.reverseShares.modal.send-email.description": "إرسال إشعار بالبريد الإلكتروني عند إنشاء مشاركة باستخدام رابط المشاركة العكسي هذا.",
"account.reverseShares.modal.max-use.label": "الحد الأقصى لعدد الاستخدامات",
"account.reverseShares.modal.max-use.description": "أقصى عدد من المرّات التي يمكن فيها استخدام هذا الرابط لإنشاء مشاركة.",
"account.reverseShare.never-expires": "لن تنتهي صلاحية هذه المشاركة العكسية أبدًا.",
"account.reverseShare.expires-on": "هذه المشاركة العكسية ستنتهي صلاحيتها في {expiration}.",
"account.reverseShares.table.no-shares": "لم يتم إنشاء أي مشاركة بعد",
"account.reverseShares.table.count.singular": "مشاركة",
"account.reverseShares.table.count.plural": "مشاركات",
"account.reverseShares.table.shares": "مشاركات",
"account.reverseShares.table.remaining": "الاستخدامات المتبقية",
"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
// /admin
"admin.title": "الإدارة",
"admin.button.users": "إدارة المستخدم",
"admin.button.shares": "إدارة المشاركة",
"admin.button.config": "الإعدادات",
"admin.version": "الإصدار",
// END /admin
// /admin/users
"admin.users.title": "إدارة المستخدم",
"admin.users.table.username": "اسم المستخدم",
"admin.users.table.email": "البريد",
"admin.users.table.admin": "المدير",
"admin.users.edit.update.title": "تحديث المستخدم {username}",
"admin.users.edit.update.admin-privileges": "صلاحيات المدير",
"admin.users.edit.update.change-password.title": "تغيير كلمة السر",
"admin.users.edit.update.change-password.field": "كلمة السر الجديدة",
"admin.users.edit.update.change-password.button": "حفظ كلمة السر الجديدة",
"admin.users.edit.update.notify.password.success": "غيرت كلمة السر بنجاح",
"admin.users.edit.delete.title": "حذف المستخدم {username}",
"admin.users.edit.delete.description": "هل تريد حقاً حذف هذا المستخدم وكل مشاركاته؟",
// showCreateUserModal.tsx
"admin.users.modal.create.title": "أنشئ مستخدمًا",
"admin.users.modal.create.username": "اسم المستخدم",
"admin.users.modal.create.email": "البريد",
"admin.users.modal.create.password": "كلمة السر",
"admin.users.modal.create.manual-password": "تعيين كلمة السر يدوياً",
"admin.users.modal.create.manual-password.description": "بدون هذا الخيار، سيتلقى المستخدم رسالة بريد إلكتروني فيها رابط لتعيين كلمة السر الخاصة به.",
"admin.users.modal.create.admin": "صلاحيات المدير",
"admin.users.modal.create.admin.description": "مع هذا الخيار، سيتمكن المستخدم من الدخول إلى لوحة الإدارة.",
// 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.title": "رفع",
"upload.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
"upload.notify.count-failed": "فشل رفع {count} ملفات. تجري المحاولة مجددًا.",
// Dropzone.tsx
"upload.dropzone.title": "رفع الملفات",
"upload.dropzone.description": "اسحب الملفات إلى هنا لبدء مشاركتك. يمكننا فقط قبول الملفات التي لا يزيد حجمها عن {maxSize} بالمجمل.",
"upload.dropzone.notify.file-too-big": "تتجاوز ملفاتك الحجم الأقصى للمشاركة والذي هو {maxSize}.",
// FileList.tsx
"upload.filelist.name": "الاسم",
"upload.filelist.size": "الحجم",
// showCreateUploadModal.tsx
"upload.modal.title": "إنشاء مشاركة",
"upload.modal.link.error.invalid": "يمكن أن يحتوي فقط على الأحرف والأرقام والشرطات السفلية والواصلات",
"upload.modal.link.error.taken": "هذا الرابط مستخدم مسبقاً",
"upload.modal.not-signed-in": "لم تقم بتسجيل الدخول",
"upload.modal.not-signed-in-description": "لن تتمكن من حذف مشاركتك يدوياً أو عرض عدد الزوار.",
"upload.modal.expires.never": "أبدًا",
"upload.modal.expires.never-long": "لا تنتهي الصلاحية أبداً",
"upload.modal.expires.error.too-long": "انتهاء الصلاحية يتجاوز الحد الأقصى لتاريخ انتهاء الصلاحية والذي هو {max}.",
"upload.modal.link.label": "الرابط",
"upload.modal.expires.label": "انتهاء الصلاحية",
"upload.modal.expires.minute-singular": "دقيقة",
"upload.modal.expires.minute-plural": "دقائق",
"upload.modal.expires.hour-singular": "ساعة",
"upload.modal.expires.hour-plural": "ساعات",
"upload.modal.expires.day-singular": "يوم",
"upload.modal.expires.day-plural": "أيام",
"upload.modal.expires.week-singular": "أسبوع",
"upload.modal.expires.week-plural": "أسابيع",
"upload.modal.expires.month-singular": "شهر",
"upload.modal.expires.month-plural": "أشهر",
"upload.modal.expires.year-singular": "سنة",
"upload.modal.expires.year-plural": "سنوات",
"upload.modal.accordion.name-and-description.title": "الاسم والوصف",
"upload.modal.accordion.name-and-description.name.placeholder": "الاسم",
"upload.modal.accordion.name-and-description.description.placeholder": "ملاحظة لمستقبلي هذه المشاركة",
"upload.modal.accordion.email.title": "مستلمو البريد الإلكتروني",
"upload.modal.accordion.email.placeholder": "أدخل مستلمي البريد",
"upload.modal.accordion.email.invalid-email": "عنوان البريد غير صحيح",
"upload.modal.accordion.security.title": "خيارات الأمان",
"upload.modal.accordion.security.password.label": "الحماية بكلمة السر",
"upload.modal.accordion.security.password.placeholder": "لا توجد كلمة سر",
"upload.modal.accordion.security.max-views.label": "الحد الأقصى للمشاهدات",
"upload.modal.accordion.security.max-views.placeholder": "لا يوجد حد",
// showCompletedUploadModal.tsx
"upload.modal.completed.never-expires": "لن تنتهي صلاحية هذه المشاركة أبدًا.",
"upload.modal.completed.expires-on": "هذه المشاركة ستنتهي صلاحيتها في {expiration}.",
"upload.modal.completed.share-ready": "المشاركة جاهزة",
// END /upload
// /share/[id]
"share.title": "المشاركة {shareId}",
"share.description": "انظر ما الذي شاركته معك!",
"share.error.visitor-limit-exceeded.title": "تم تجاوز حد المشاهدات",
"share.error.visitor-limit-exceeded.description": "تم تجاوز الحد الأقصى لزوار هذه المشاركة.",
"share.error.removed.title": "تمت إزالة المشاركة",
"share.error.not-found.title": "المشاركة غير موجودة",
"share.error.not-found.description": "المشاركة التي تبحث عنها غير موجودة.",
"share.modal.password.title": "كلمة السر مطلوبة",
"share.modal.password.description": "للوصول إلى هذه المشاركة الرجاء إدخال كلمة سر المشاركة.",
"share.modal.password": "كلمة السر",
"share.modal.error.invalid-password": "كلمة السر غير صحيحة",
"share.button.download-all": "تنزيل الكل",
"share.notify.download-all-preparing": "يتم تحضير المشاركة. حاول مرة أخرى في بضع دقائق.",
"share.modal.file-link": "رابط الملف",
"share.table.name": "الاسم",
"share.table.size": "الحجم",
"share.modal.file-preview.error.not-supported.title": "المعاينة غير مدعومة",
"share.modal.file-preview.error.not-supported.description": "معاينة هذا النوع من الملفات غير مدعومة. الرجاء تنزيل الملف لعرضه.",
// END /share/[id]
// /share/[id]/edit
"share.edit.title": "تحرير {shareId}",
"share.edit.append-upload": "إضافة ملف",
"share.edit.notify.generic-error": "حدث خطأ أثناء إنهاء مشاركتك.",
"share.edit.notify.save-success": "تم تحديث المشاركة بنجاح",
// END /share/[id]/edit
// /admin/config
"admin.config.title": "الإعدادات",
"admin.config.category.general": "عام",
"admin.config.category.share": "مشاركة",
"admin.config.category.email": "البريد",
"admin.config.category.smtp": "بروتوكول نقل البريد البسيط SMTP",
"admin.config.category.oauth": "الدخول بحساب تواصل اجتماعي",
"admin.config.general.app-name": "اسم التطبيق",
"admin.config.general.app-name.description": "اسم التطبيق",
"admin.config.general.app-url": "رابط التطبيق",
"admin.config.general.app-url.description": "الرابط الذي تكون مشاركة Pingvin صالحة عليه",
"admin.config.general.show-home-page": "إظهار الصفحة الرئيسية",
"admin.config.general.show-home-page.description": "تحديد ما إذا كان سيتم عرض الصفحة الرئيسية",
"admin.config.general.session-duration": "مدة الجلسة",
"admin.config.general.session-duration.description": "الوقت بالساعات الذي يجب على المستخدم بعده إعادة تسجيل الدخول (الافتراضي: 3 أشهر).",
"admin.config.general.logo": "الشعار",
"admin.config.general.logo.description": "يمكنك تغيير شعارك عن طريق تحميل صورة جديدة. يجب أن تكون الصورة PNG ويجب أن يكون تنسيقها 1:1.",
"admin.config.general.logo.placeholder": "اختر صورة",
"admin.config.email.enable-share-email-recipients": "تفعيل مستلمي البريد الإلكتروني لهذه المشاركة",
"admin.config.email.enable-share-email-recipients.description": "السماح لرسائل البريد بأن تُشارك المستلمين. لا تفعّل هذا الخيار ما لم تفعّل SMTP مسبقًا.",
"admin.config.email.share-recipients-subject": "عنوان الرسالة لمستلمي المشاركة",
"admin.config.email.share-recipients-subject.description": "عنوان البريد الذي سيُرسَل لمستقبِلي المشاركة.",
"admin.config.email.share-recipients-message": "رسالتك لمستقبِلي المشاركة",
"admin.config.email.share-recipients-message.description": "الرسالة التي ستُرسل لمستقبِلي المشاركة. يمكنك استخدام هذه المتغيرات:\n{creator} - اسم المستخدم الذي أنشأ المشاركة\n{shareUrl} - رابط المشاركة\n{desc} - وصف المشاركة\n{expires} - تاريخ انتهاء صلاحية المشاركة\nستتم كتابة قيم هذه المتغيرات تلقائيًا.",
"admin.config.email.reverse-share-subject": "عنوان المشاركة العكسية",
"admin.config.email.reverse-share-subject.description": "عنوان البريد الذي سيُرسل عندما يُنشئ شخص ما مشاركةً باستخدام رابط المشاركة العكسية الخاص بك.",
"admin.config.email.reverse-share-message": "رسالة المشاركة العكسية",
"admin.config.email.reverse-share-message.description": "الرسالة التي ستُرسل عندما يُنشئ شخص ما مشاركة باستخدام رابط المشاركة الخاص بك. سيُوضع اسم المُنشِئ ورابط المشاركة مكان {shareUrl}.",
"admin.config.email.reset-password-subject": "رسالة إعادة تعيين كلمة السر",
"admin.config.email.reset-password-subject.description": "عنوان البريد الذي سيُرسل حين يطلب مستخدم ما إعادة تعيين كلمة سرّه.",
"admin.config.email.reset-password-message": "رسالة إعادة تعيين كلمة السر",
"admin.config.email.reset-password-message.description": "الرسالة التي ستُرسل عندما يطلب المستخدم إعادة تعيين كلمة سرّه. سيُوضع رابط إعادة تعيين كلمة السر مكان {url}.",
"admin.config.email.invite-subject": "عنوان الدعوة",
"admin.config.email.invite-subject.description": "عنوان البريد الذي سيُرسل عندما يقوم المشرف بدعوة مستخدم ما.",
"admin.config.email.invite-message": "رسالة الدعوة",
"admin.config.email.invite-message.description": "الرسالة التي ستُرسل عندما يدعو مشرفٌ مستخدمًا. سيُوضع رابط الدعوة مكان {url} وكلمة السر مكان {password}.",
"admin.config.share.allow-registration": "السماح بالتسجيل",
"admin.config.share.allow-registration.description": "إتاحة تسجيل حساب جديد",
"admin.config.share.allow-unauthenticated-shares": "السماح بالمشاركات غير المصادق عليها",
"admin.config.share.allow-unauthenticated-shares.description": "إتاحة إنشاء المشاركات للمستخدمين غير الموثقين",
"admin.config.share.max-expiration": "أبعد زمن لانتهاء الصلاحية",
"admin.config.share.max-expiration.description": "أطول زمن لانتهاء صلاحية المشاركات بالساعات. الصفر يعني أن المشاركة لن تنتهي صلاحيتها.",
"admin.config.share.max-size": "أكبر حجم",
"admin.config.share.max-size.description": "أكبر حجم للمشاركة مقيسًا بالبايت",
"admin.config.share.zip-compression-level": "مستوى ضغط الZip",
"admin.config.share.zip-compression-level.description": "ضبط الميزان بين حجم الملف وسرعة الضغط. يمكنك إدخال قيم بين 0 إلى 9، حيث 0 تعني بدون ضغط و9 تعني أقصى ضغط. ",
"admin.config.share.chunk-size": "حجم القطعة",
"admin.config.share.chunk-size.description": "ضبط حجم القطعة (بالبايت) لملفاتك المرفوعة للموازنة بين الكفاءة والفعالية حسب قوة اتصالك بالإنترنت. القطع الأصغر يمكن أن ترفع معدل النجاح في حال كان اتصالك بالإنترنت غير مستقر، بينما القطع الأكبر يمكنها أن تُسرّع رفع الملفات في حال كان الاتصال بالإنترنت مستقرًا.",
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
"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.enabled": "مفعل",
"admin.config.smtp.enabled.description": "تفعيل الـSMTP. لا تفعّله إلا إذا قمت بإدخال المضيف، والمنفذ، والبريد الإلكتروني، واسم المستخدم، وكلمة السر لخادم الـSMTP.",
"admin.config.smtp.host": "المُضيف",
"admin.config.smtp.host.description": "مضيف خادم الـSMTP",
"admin.config.smtp.port": "المنفذ",
"admin.config.smtp.port.description": "منفذ خادم الـSMTP",
"admin.config.smtp.email": "البريد الإلكتروني",
"admin.config.smtp.email.description": "عنوان البريد الذي ستُرسَل الرسائل منه",
"admin.config.smtp.username": "اسم المستخدم",
"admin.config.smtp.username.description": "اسم المستخدم لخادم الـSMTP",
"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.description": "تفعيل خيار الدخول بحساب GitHub",
"admin.config.oauth.github-client-id": "GitHub Client ID",
"admin.config.oauth.github-client-id.description": "معرف العميل لتطبيق GitHub OAuth",
"admin.config.oauth.github-client-secret": "الرمز السرّي لـGitHub Client",
"admin.config.oauth.github-client-secret.description": "الرّمز السرّي للعميل لتطبيق GitHub OAuth",
"admin.config.oauth.google-enabled": "Google",
"admin.config.oauth.google-enabled.description": "تفعيل خيار الدخول بحساب Google",
"admin.config.oauth.google-client-id": "Google Client ID",
"admin.config.oauth.google-client-id.description": "معرف العميل لتطبيق Google OAuth",
"admin.config.oauth.google-client-secret": "الرمز السرّي لـ Google Client",
"admin.config.oauth.google-client-secret.description": "الرّمز السرّي للعميل لتطبيق Google OAuth",
"admin.config.oauth.microsoft-enabled": "Microsoft",
"admin.config.oauth.microsoft-enabled.description": "تفعيل خيار الدخول بحساب Microsoft",
"admin.config.oauth.microsoft-tenant": "Microsoft Tenant",
"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.description": "معرف العميل لتطبيق Microsoft OAuth",
"admin.config.oauth.microsoft-client-secret": "الرمز السرّي لـMicrosoft Client",
"admin.config.oauth.microsoft-client-secret.description": "الرّمز السرّي للعميل لتطبيق Microsoft OAuth",
"admin.config.oauth.discord-enabled": "Discord",
"admin.config.oauth.discord-enabled.description": "تفعيل خيار الدخول بحساب Discord",
"admin.config.oauth.discord-limited-guild": "مُعرِّف خادم Discord المحدود",
"admin.config.oauth.discord-limited-guild.description": "حصر تسجيل الدخول على المستخدمين الموجودين في خادم محدّد. اترك هذا الخيار فارغًا لتعطيله.",
"admin.config.oauth.discord-client-id": "Discord Client ID",
"admin.config.oauth.discord-client-id.description": "معرف العميل لتطبيق Discord OAuth",
"admin.config.oauth.discord-client-secret": "الرمز السرّي لـDiscord Client",
"admin.config.oauth.discord-client-secret.description": "الرّمز السرّي للعميل لتطبيق Discord OAuth",
"admin.config.oauth.oidc-enabled": "OpenID Connect",
"admin.config.oauth.oidc-enabled.description": "تفعيل الدخول باستخدام OpenID Connect",
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
"admin.config.oauth.oidc-discovery-uri.description": "رابط الاستكشاف لتطبيق OpenID Connect OAuth",
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
"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 users 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 users 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.description": "معرف العميل لتطبيق OpenID Connect OAuth",
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client secret",
"admin.config.oauth.oidc-client-secret.description": "الرّمز السرّي للعميل لتطبيق OpenID Connect OAuth",
// 404
"404.description": "هذه الصفحة غير موجودة.",
"404.button.home": "أعدني للصفحة الرئيسية",
// error
"error.title": "خطأ",
"error.description": "عذرًا!",
"error.button.back": "العودة",
"error.msg.default": "حَدث خطأ ما.",
"error.msg.access_denied": "قمت بإلغاء عملية المصادقة، الرجاء المحاولة مرة أخرى.",
"error.msg.expired_token": "استغرقت عملية المصادقة وقتًا طويلًا، يرجى المحاولة مرة أخرى.",
"error.msg.invalid_token": "خطأ داخلي",
"error.msg.no_user": "المستخدم المرتبط بهذا الحساب {0} غير موجود.",
"error.msg.no_email": "لا يمكن الحصول على عنوان البريد الإلكتروني من هذا الحساب {0}.",
"error.msg.already_linked": "حساب {0} هذا مرتبط بالفعل بحساب آخر.",
"error.msg.not_linked": "لم يتم ربط حساب {0} هذا بأي حساب حتى الآن.",
"error.msg.unverified_account": "لم يتم التحقق من حساب {0} هذا، يرجى المحاولة مرة أخرى بعد التحقق.",
"error.msg.user_not_allowed": "غير مسموح لك بتسجيل الدخول.",
"error.msg.cannot_get_user_info": "فشلت عملية جلب معلومات المستخدم الخاصة بك من حساب {0} هذا.",
"error.param.provider_github": "GitHub",
"error.param.provider_google": "Google",
"error.param.provider_microsoft": "Microsoft",
"error.param.provider_discord": "Discord",
"error.param.provider_oidc": "OpenID Connect",
// Common translations
"common.button.save": "حفظ",
"common.button.create": "إنشاء",
"common.button.submit": "إرسال",
"common.button.delete": "حذف",
"common.button.cancel": "إلغاء",
"common.button.confirm": "تأكيد",
"common.button.disable": "إيقاف",
"common.button.share": "مشاركة",
"common.button.generate": "توليد",
"common.button.done": "تم",
"common.text.link": "الرابط",
"common.text.navigate-to-link": "الذهاب إلى الرابط",
"common.text.or": "أو",
"common.button.go-back": "العودة",
"common.button.go-home": "العودة للصفحة الرئيسية",
"common.notify.copied": "تم نسخ الرابط إلى الحافظة",
"common.success": "تم",
"common.error": "خطأ",
"common.error.unknown": "حدث خطأ غير معروف",
"common.error.invalid-email": "عنوان البريد غير صحيح",
"common.error.too-short": "يجب أن يكون على الأقل {length} حرفًا",
"common.error.too-long": "يجب أن يكون على الأكثر {length} حرفًا",
"common.error.exact-length": "يجب أن يكون بالضبط {length} حرفًا",
"common.error.invalid-number": "يجب أن يكون رقماً",
"common.error.field-required": "هذا الحقل مطلوب"
};

View File

@@ -33,6 +33,13 @@ export default {
"signin.button.submit": "Log ind",
"signIn.notify.totp-required.title": "2-faktor login påkrævet",
"signIn.notify.totp-required.description": "Indtast den aktuelle engangskode fra din 2-faktor Authenticator",
"signIn.oauth.or": "OR",
"signIn.oauth.signInWith": "Sign in with",
"signIn.oauth.github": "GitHub",
"signIn.oauth.google": "Google",
"signIn.oauth.microsoft": "Microsoft",
"signIn.oauth.discord": "Discord",
"signIn.oauth.oidc": "OpenID",
// END /auth/signin
// /auth/signup
"signup.title": "Opret en bruger",
@@ -44,10 +51,14 @@ export default {
"signup.input.email.placeholder": "Din e-mail",
"signup.button.submit": "Lad os komme i gang",
// END /auth/signup
// /auth/totp
"totp.title": "TOTP Authentication",
"totp.button.signIn": "Log ind",
// END /auth/totp
// /auth/reset-password
"resetPassword.title": "Glemt din adgangskode?",
"resetPassword.description": "Indtast din e-mail for at nulstille din adgangskode.",
"resetPassword.notify.success": "En e-mail er blevet sendt med et link til at nulstille din adgangskode.",
"resetPassword.notify.success": "A message with a link to reset your password has been sent if the email exists.",
"resetPassword.button.back": "Tilbage til login",
"resetPassword.text.resetPassword": "Nulstil adgangskode",
"resetPassword.text.enterNewPassword": "Indtast din nye adgangskode",
@@ -62,7 +73,20 @@ export default {
"account.card.password.title": "Adgangskode",
"account.card.password.old": "Gammel adgangskode",
"account.card.password.new": "Ny adgangskode",
"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.notify.password.success": "Adgangskoden er ændret",
"account.card.oauth.title": "Social login",
"account.card.oauth.github": "GitHub",
"account.card.oauth.google": "Google",
"account.card.oauth.microsoft": "Microsoft",
"account.card.oauth.discord": "Discord",
"account.card.oauth.oidc": "OpenID",
"account.card.oauth.link": "Link",
"account.card.oauth.unlink": "Unlink",
"account.card.oauth.unlinked": "Unlinked",
"account.modal.unlink.title": "Unlink account",
"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.notify.oauth.unlinked.success": "Unlinked successfully",
"account.card.security.title": "Sikkerhed",
"account.card.security.totp.enable.description": "Indtast din nuværende adgangskode for at begynde opsætningen af 2-faktor login",
"account.card.security.totp.disable.description": "Indtast din nuværende adgangskode for at begynde opsætningen af 2-faktor login",
@@ -72,7 +96,7 @@ export default {
"account.modal.totp.step2": "Trin 2: Valider din kode",
"account.modal.totp.enterManually": "Indtast manuelt",
"account.modal.totp.code": "Kode",
"account.modal.totp.clickToCopy": "Klik for at kopiere",
"common.button.clickToCopy": "Klik for at kopiere",
"account.modal.totp.verify": "Bekræft",
"account.notify.totp.disable": "2-faktor blev deaktiveret",
"account.notify.totp.enable": "2-faktor blev deaktiveret",
@@ -146,6 +170,7 @@ export default {
// /admin
"admin.title": "Administration",
"admin.button.users": "Brugeradministration",
"admin.button.shares": "Share management",
"admin.button.config": "Konfiguration",
"admin.version": "Version",
// END /admin
@@ -172,6 +197,15 @@ export default {
"admin.users.modal.create.admin": "Admin rettigheder",
"admin.users.modal.create.admin.description": "If checked, the user will be able to access the admin panel.",
// END /admin/users
// /admin/shares
"admin.shares.title": "Share management",
"admin.shares.table.id": "Share ID",
"admin.shares.table.username": "Creator",
"admin.shares.table.visitors": "Besøgende",
"admin.shares.table.expires": "Expires At",
"admin.shares.edit.delete.title": "Delete share {id}",
"admin.shares.edit.delete.description": "Do you really want to delete this share?",
// END /admin/shares
// /upload
"upload.title": "Upload",
"upload.notify.generic-error": "Der opstod en fejl under afslutningen af din deling.",
@@ -191,6 +225,7 @@ export default {
"upload.modal.not-signed-in-description": "Du vil ikke være i stand til at slette din deling manuelt og se antallet af besøgende.",
"upload.modal.expires.never": "aldrig",
"upload.modal.expires.never-long": "Udløber aldrig",
"upload.modal.expires.error.too-long": "Udløbsdatoen overskrider den maksimalt tilladte udløbsdato på {max}.",
"upload.modal.link.label": "Link",
"upload.modal.expires.label": "Udløb",
"upload.modal.expires.minute-singular": "Minut",
@@ -205,8 +240,9 @@ export default {
"upload.modal.expires.month-plural": "Måneder",
"upload.modal.expires.year-singular": "År",
"upload.modal.expires.year-plural": "År",
"upload.modal.accordion.description.title": "Beskrivelse",
"upload.modal.accordion.description.placeholder": "Bemærkning til modtagerne af dette share",
"upload.modal.accordion.name-and-description.title": "Name and description",
"upload.modal.accordion.name-and-description.name.placeholder": "Navn",
"upload.modal.accordion.name-and-description.description.placeholder": "Note for the recipients of this share",
"upload.modal.accordion.email.title": "E-mail modtagere",
"upload.modal.accordion.email.placeholder": "Indtast e-mail modtagere",
"upload.modal.accordion.email.invalid-email": "Ugyldig e-mailadresse",
@@ -238,20 +274,29 @@ export default {
"share.table.name": "Navn",
"share.table.size": "Størrelse",
"share.modal.file-preview.error.not-supported.title": "Forhåndsvisning ikke understøttet",
"share.modal.file-preview.error.not-supported.description": "En forhåndsvisning for thise filtype er ikke understøttet. Download venligst filen for at se den.",
"share.modal.file-preview.error.not-supported.description": "A preview for this file type is unsupported. Please download the file to view it.",
// END /share/[id]
// /share/[id]/edit
"share.edit.title": "Rediger {shareId}",
"share.edit.append-upload": "Append file",
"share.edit.notify.generic-error": "An error occurred while finishing your share.",
"share.edit.notify.save-success": "Share updated successfully",
// END /share/[id]/edit
// /admin/config
"admin.config.title": "Konfiguration",
"admin.config.category.general": "Generelt",
"admin.config.category.share": "Del",
"admin.config.category.email": "E-mail",
"admin.config.category.smtp": "SMTP",
"admin.config.category.oauth": "Social Login",
"admin.config.general.app-name": "App-navn",
"admin.config.general.app-name.description": "Navnet på applikationen",
"admin.config.general.app-url": "App URL",
"admin.config.general.app-url.description": "På hvilken URL Pingvin Share er tilgængelig",
"admin.config.general.show-home-page": "Vis forside",
"admin.config.general.show-home-page.description": "Om forsiden skal vises",
"admin.config.general.session-duration": "Session Duration",
"admin.config.general.session-duration.description": "Time in hours after which a user must log in again (default: 3 months).",
"admin.config.general.logo": "Logo",
"admin.config.general.logo.description": "Skift dit logo ved at uploade et nyt billede. Billedet skal være PNG og skal have formatet 1:1.",
"admin.config.general.logo.placeholder": "Vælg billede",
@@ -277,10 +322,16 @@ export default {
"admin.config.share.allow-registration.description": "Om alle skal kunne oprette en bruger",
"admin.config.share.allow-unauthenticated-shares": "Tillad uautoriserede delinger",
"admin.config.share.allow-unauthenticated-shares.description": "Whether unauthenticated users can create shares",
"admin.config.share.max-expiration": "Maks. udløb",
"admin.config.share.max-expiration.description": "Maximum share expiration in hours. Set to 0 to allow unlimited expiration.",
"admin.config.share.max-size": "Maks. størrelse",
"admin.config.share.max-size.description": "Maksimal filstørrelse i bytes",
"admin.config.share.zip-compression-level": "Zip compression level",
"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.chunk-size": "Chunk size",
"admin.config.share.chunk-size.description": "Adjust the chunk size (in bytes) for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks speed up uploads for stable connections.",
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
"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.enabled": "Aktiveret",
"admin.config.smtp.enabled.description": "Om SMTP er aktiveret. Aktiver kun SMTP, hvis du har indtastet SMTP-server vært, port, e-mail, bruger og adgangskode.",
"admin.config.smtp.host": "Vært",
@@ -294,9 +345,81 @@ export default {
"admin.config.smtp.password": "Adgangskode",
"admin.config.smtp.password.description": "Adgangskoden til SMTP serveren",
"admin.config.smtp.button.test": "Send test e-mail",
"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": "Tillad registrering",
"admin.config.oauth.allow-registration.description": "Allow users to register via social login",
"admin.config.oauth.ignore-totp": "Ignore TOTP",
"admin.config.oauth.ignore-totp.description": "Whether to ignore TOTP when user using social login",
"admin.config.oauth.disable-password": "Disable password login",
"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.description": "Whether GitHub login is enabled",
"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-secret": "GitHub Client secret",
"admin.config.oauth.github-client-secret.description": "Client secret of the GitHub OAuth app",
"admin.config.oauth.google-enabled": "Google",
"admin.config.oauth.google-enabled.description": "Whether Google login is enabled",
"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-secret": "Google Client secret",
"admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app",
"admin.config.oauth.microsoft-enabled": "Microsoft",
"admin.config.oauth.microsoft-enabled.description": "Whether Microsoft login is enabled",
"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-client-id": "Microsoft Client ID",
"admin.config.oauth.microsoft-client-id.description": "Client ID of the Microsoft OAuth app",
"admin.config.oauth.microsoft-client-secret": "Microsoft Client secret",
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
"admin.config.oauth.discord-enabled": "Discord",
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
"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-client-id": "Discord Client ID",
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
"admin.config.oauth.discord-client-secret": "Discord Client secret",
"admin.config.oauth.discord-client-secret.description": "Client secret of the Discord OAuth app",
"admin.config.oauth.oidc-enabled": "OpenID Connect",
"admin.config.oauth.oidc-enabled.description": "Whether OpenID Connect login is enabled",
"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-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-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 users 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 users 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.description": "Client ID of the OpenID Connect OAuth app",
"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",
// 404
"404.description": "Ups! Denne side findes ikke.",
"404.button.home": "Gå tilbage",
// error
"error.title": "Fejl",
"error.description": "Hovsa!",
"error.button.back": "Gå tilbage",
"error.msg.default": "Something went wrong.",
"error.msg.access_denied": "You canceled the authentication process, please try again.",
"error.msg.expired_token": "The authentication process took too long, please try again.",
"error.msg.invalid_token": "Intern Fejl",
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
"error.msg.no_email": "Can't get email address from this {0} account.",
"error.msg.already_linked": "This {0} account is already linked to another account.",
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
"error.msg.unverified_account": "This {0} account is unverified, please try again after verification.",
"error.msg.user_not_allowed": "Du har ikke tilladelse til at logge ind.",
"error.msg.cannot_get_user_info": "Can not get your user info from this {0} account.",
"error.param.provider_github": "GitHub",
"error.param.provider_google": "Google",
"error.param.provider_microsoft": "Microsoft",
"error.param.provider_discord": "Discord",
"error.param.provider_oidc": "OpenID Connect",
// Common translations
"common.button.save": "Gem",
"common.button.create": "Opret",
@@ -309,8 +432,10 @@ export default {
"common.button.generate": "Generer",
"common.button.done": "Færdig",
"common.text.link": "Link",
"common.text.navigate-to-link": "Go to the link",
"common.text.or": "eller",
"common.button.go-back": "Gå tilbage",
"common.button.go-home": "Go home",
"common.notify.copied": "Linket blev kopieret til udklipsholderen",
"common.success": "Success",
"common.error": "Fejl",

View File

@@ -33,6 +33,13 @@ export default {
"signin.button.submit": "Anmelden",
"signIn.notify.totp-required.title": "Zwei-Faktor-Authentifizierung benötigt",
"signIn.notify.totp-required.description": "Bitte füge deinen Zwei-Faktor-Authentifizierungscode ein",
"signIn.oauth.or": "ODER",
"signIn.oauth.signInWith": "Anmelden mit",
"signIn.oauth.github": "GitHub",
"signIn.oauth.google": "Google",
"signIn.oauth.microsoft": "Microsoft",
"signIn.oauth.discord": "Discord",
"signIn.oauth.oidc": "OpenID",
// END /auth/signin
// /auth/signup
"signup.title": "Erstelle ein Konto",
@@ -44,10 +51,14 @@ export default {
"signup.input.email.placeholder": "Deine Emailadresse",
"signup.button.submit": "Lass uns loslegen",
// END /auth/signup
// /auth/totp
"totp.title": "TOTP Authentifizierung",
"totp.button.signIn": "Anmelden",
// END /auth/totp
// /auth/reset-password
"resetPassword.title": "Passwort vergessen?",
"resetPassword.description": "Gib deine Email Adresse ein, um dein Passwort zurückzusetzen.",
"resetPassword.notify.success": "Ein Link zum Rücksetzen des Passwortes wurde an deine Emailadresse versandt.",
"resetPassword.notify.success": "Wir haben dir einen Link gesendet, unter dem du dein Passwort zurücksetzen kannst.",
"resetPassword.button.back": "Zurück zur Anmeldeseite",
"resetPassword.text.resetPassword": "Passwort zurücksetzen",
"resetPassword.text.enterNewPassword": "Gib dein neues Passwort ein",
@@ -62,7 +73,20 @@ export default {
"account.card.password.title": "Passwort",
"account.card.password.old": "Altes Passwort",
"account.card.password.new": "Neues Passwort",
"account.card.password.noPasswordSet": "Du hast kein Passwort erstellt. Wenn du dich mit E-Mail und Passwort anmelden möchtest, musst du ein Passwort festlegen.",
"account.notify.password.success": "Passwort erfolgreich geändert",
"account.card.oauth.title": "Anmeldung über soziale Netzwerke",
"account.card.oauth.github": "GitHub",
"account.card.oauth.google": "Google",
"account.card.oauth.microsoft": "Microsoft",
"account.card.oauth.discord": "Discord",
"account.card.oauth.oidc": "OpenID",
"account.card.oauth.link": "Verknüpfen",
"account.card.oauth.unlink": "Verknüpfung aufheben",
"account.card.oauth.unlinked": "Verknüpfung aufgehoben",
"account.modal.unlink.title": "Kontoverknüpfung aufheben",
"account.modal.unlink.description": "Das Entfernen der Verknüpfung mit deinem sozialen Konten kann dazu führen, dass du dein Konto verlierst, wenn du dich nicht an deinen Benutzernamen und dein Passwort erinnerst.",
"account.notify.oauth.unlinked.success": "Verknüpfung erfolgreich aufgehoben",
"account.card.security.title": "Sicherheit",
"account.card.security.totp.enable.description": "Gib dein aktuelles Passwort ein, um TOTP zu aktivieren",
"account.card.security.totp.disable.description": "Gib dein aktuelles Passwort ein, um TOTP zu deaktivieren",
@@ -72,7 +96,7 @@ export default {
"account.modal.totp.step2": "Schritt 2: Bestätige deinen Code",
"account.modal.totp.enterManually": "Manuell eingeben",
"account.modal.totp.code": "Code",
"account.modal.totp.clickToCopy": "Klicken zum Kopieren",
"common.button.clickToCopy": "Klicken zum Kopieren",
"account.modal.totp.verify": "Überprüfen",
"account.notify.totp.disable": "TOTP erfolgreich deaktiviert",
"account.notify.totp.enable": "TOTP erfolgreich aktiviert",
@@ -90,9 +114,9 @@ export default {
// /account/shares
"account.shares.title": "Meine Freigaben",
"account.shares.title.empty": "Es ist so leer hier 👀",
"account.shares.description.empty": "Du hast keine Freigaben eingerichtet.",
"account.shares.description.empty": "Du hast keine Freigaben erstellt.",
"account.shares.button.create": "Erstelle eine",
"account.shares.info.title": "Teile deine Information",
"account.shares.info.title": "Freigabe Informationen",
"account.shares.table.id": "ID",
"account.shares.table.name": "Name",
"account.shares.table.description": "Beschreibung",
@@ -146,6 +170,7 @@ export default {
// /admin
"admin.title": "Verwaltung",
"admin.button.users": "Benutzerverwaltung",
"admin.button.shares": "Freigabenverwaltung",
"admin.button.config": "Konfiguration",
"admin.version": "Version",
// END /admin
@@ -172,6 +197,15 @@ export default {
"admin.users.modal.create.admin": "Administratorrechte",
"admin.users.modal.create.admin.description": "Wenn aktiviert, kann der Benutzer auf das Administrator-Panel zugreifen.",
// END /admin/users
// /admin/shares
"admin.shares.title": "Freigabenverwaltung",
"admin.shares.table.id": "ID teilen",
"admin.shares.table.username": "Ersteller",
"admin.shares.table.visitors": "Besucher",
"admin.shares.table.expires": "Läuft ab am",
"admin.shares.edit.delete.title": "Lösche Freigabe {id}",
"admin.shares.edit.delete.description": "Möchtest du wirklich diese Freigabe löschen?",
// END /admin/shares
// /upload
"upload.title": "Upload",
"upload.notify.generic-error": "Während der Erstellung der Freigabe ist ein Fehler aufgetreten.",
@@ -191,6 +225,7 @@ export default {
"upload.modal.not-signed-in-description": "Du wirst deine Freigabe nicht löschen können oder die Besucheranzahl sehen.",
"upload.modal.expires.never": "niemals",
"upload.modal.expires.never-long": "Läuft nicht ab",
"upload.modal.expires.error.too-long": "Ablauf überschreitet das maximale Ablaufdatum von {max}.",
"upload.modal.link.label": "Link",
"upload.modal.expires.label": "Gültig bis",
"upload.modal.expires.minute-singular": "Minute",
@@ -205,8 +240,9 @@ export default {
"upload.modal.expires.month-plural": "Monate",
"upload.modal.expires.year-singular": "Jahr",
"upload.modal.expires.year-plural": "Year",
"upload.modal.accordion.description.title": "Beschreibung",
"upload.modal.accordion.description.placeholder": "Hinweis für die Empfänger dieser Freigabe",
"upload.modal.accordion.name-and-description.title": "Name und Beschreibung",
"upload.modal.accordion.name-and-description.name.placeholder": "Name",
"upload.modal.accordion.name-and-description.description.placeholder": "Hinweis für die Empfänger dieser Freigabe",
"upload.modal.accordion.email.title": "Email Empfänger",
"upload.modal.accordion.email.placeholder": "Email der Empfänger eingeben",
"upload.modal.accordion.email.invalid-email": "Ungültige Emailadresse",
@@ -238,25 +274,34 @@ export default {
"share.table.name": "Name",
"share.table.size": "Größe",
"share.modal.file-preview.error.not-supported.title": "Vorschau wird nicht unterstützt",
"share.modal.file-preview.error.not-supported.description": "Eine Vorschau für diesen Dateityp wird nicht unterstützt. Bitte lade die Datei herunter, um sie anzuzeigen.",
"share.modal.file-preview.error.not-supported.description": "Eine Vorschau für diesen Dateityp wird nicht unterstützt. Bitte lade die Datei herunter, um sie anzusehen.",
// END /share/[id]
// /share/[id]/edit
"share.edit.title": "{shareId} bearbeiten",
"share.edit.append-upload": "Datei anfügen",
"share.edit.notify.generic-error": "Während der Erstellung der Freigabe ist ein Fehler aufgetreten.",
"share.edit.notify.save-success": "Freigabe erfolgreich aktualisiert",
// END /share/[id]/edit
// /admin/config
"admin.config.title": "Einstellungen",
"admin.config.category.general": "Allgemein",
"admin.config.category.share": "Freigabe",
"admin.config.category.email": "E-Mail",
"admin.config.category.smtp": "SMTP",
"admin.config.category.oauth": "OAuth-Anmeldung",
"admin.config.general.app-name": "App-Name",
"admin.config.general.app-name.description": "Name der Applikation",
"admin.config.general.app-url": "App-URL",
"admin.config.general.app-url.description": "Auf welcher URL Pingvin Share verfügbar ist",
"admin.config.general.show-home-page": "Startseite anzeigen",
"admin.config.general.show-home-page.description": "Ob die Startseite angezeigt werden soll",
"admin.config.general.session-duration": "Session-Dauer",
"admin.config.general.session-duration.description": "Zeit in Stunden, nach der ein Benutzer sich erneut anmelden muss (Voreinstellung: 3 Monate).",
"admin.config.general.logo": "Logo",
"admin.config.general.logo.description": "Ändere dein Logo durch Hochladen eines Bildes. Das Bild muss im PNG-Format vorliegen und sollte mit Seitenverhältnis 1:1 sein.",
"admin.config.general.logo.placeholder": "Bild auswählen",
"admin.config.email.enable-share-email-recipients": "Erlaube das Teilen der Freigabe via Email",
"admin.config.email.enable-share-email-recipients.description": "Gibt an, ob Emails an Freigabe-Empfänger ermöglicht werden sollen. Aktiviere dies nur, wenn Du SMTP aktivierst hast.",
"admin.config.email.enable-share-email-recipients.description": "Gibt an, ob Emails an Freigabe-Empfänger ermöglicht werden sollen. Aktiviere dies nur, wenn du SMTP aktivierst hast.",
"admin.config.email.share-recipients-subject": "Betreff für Freigabe-Empfänger",
"admin.config.email.share-recipients-subject.description": "Betreff der E-Mail, die an die Freigabe-Empfänger gesendet wird.",
"admin.config.email.share-recipients-message": "Nachricht für Freigabe-Empfänger",
@@ -277,12 +322,18 @@ export default {
"admin.config.share.allow-registration.description": "Gibt an, ob eine Registrierung erlaubt ist",
"admin.config.share.allow-unauthenticated-shares": "Nicht authentifizierte Freigaben erlauben",
"admin.config.share.allow-unauthenticated-shares.description": "Gibt an, ob nicht authentifizierte Benutzer Freigaben erstellen können",
"admin.config.share.max-expiration": "Max. Ablaufdatum",
"admin.config.share.max-expiration.description": "Maximale Ablaufzeit in Stunden. Auf 0 setzen, um kein Ablaufdatum zu definieren.",
"admin.config.share.max-size": "Maximale Größe",
"admin.config.share.max-size.description": "Maximale Größe einer Freigabe in Bytes",
"admin.config.share.zip-compression-level": "Zip Komprimierungsstufe",
"admin.config.share.zip-compression-level.description": "Passe den Wert an, um ein Gleichgewicht zwischen Dateigröße und Komprimierungsgeschwindigkeit herzustellen. Gültige Werte liegen zwischen 0 und 9, wobei 0 für keine Komprimierung und 9 für maximale Komprimierung steht.",
"admin.config.share.chunk-size": "Chunkgröße",
"admin.config.share.chunk-size.description": "Passe die Chunkgröße (in Bytes) für deine Uploads an, um die Zuverlässigkeit deiner Internetverbindung auszugleichen. Kleinere Chunks können die Erfolgsraten für instabile Verbindungen verbessern, während größere Chunks Uploads für stabile Verbindungen beschleunigen können.",
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
"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.enabled": "Aktiviert",
"admin.config.smtp.enabled.description": "Gibt an, ob SMTP aktiviert ist. Aktiviere dies nur, wenn Du den Host, den Port, die Email, den Benutzernamen und das Passwort deines SMTP-Servers eingegeben hast.",
"admin.config.smtp.enabled.description": "Gibt an, ob SMTP aktiviert ist. Aktiviere dies nur, wenn du den Host, den Port, die Email, den Benutzernamen und das Passwort deines SMTP-Servers eingegeben hast.",
"admin.config.smtp.host": "Host",
"admin.config.smtp.host.description": "Host des SMTP-Servers",
"admin.config.smtp.port": "Port",
@@ -294,9 +345,81 @@ export default {
"admin.config.smtp.password": "Passwort",
"admin.config.smtp.password.description": "Passwort des SMTP-Servers",
"admin.config.smtp.button.test": "Test-E-Mail senden",
"admin.config.smtp.allow-unauthorized-certificates": "Vertrauen von nicht authentifizierten SMTP-Server-Zertifikaten",
"admin.config.smtp.allow-unauthorized-certificates.description": "Verwenden Sie diese Option nur, wenn Sie selbst signierten Zertifikaten vertrauen müssen.",
"admin.config.oauth.allow-registration": "Registrierung erlauben",
"admin.config.oauth.allow-registration.description": "Benutzern erlauben, sich über Soziale Netzwerke zu registrieren",
"admin.config.oauth.ignore-totp": "TOTP ignorieren",
"admin.config.oauth.ignore-totp.description": "Gibt an, ob TOTP ignoriert werden soll, wenn sich der Benutzer über Soziale Netzwerke anmeldet",
"admin.config.oauth.disable-password": "Anmelden mit Passwort deaktivieren",
"admin.config.oauth.disable-password.description": "Deaktiviert das Anmelden mit Passwort\nStelle vor Aktivierung dieser Konfiguration sicher, dass ein OAuth-Provider korrekt konfiguriert ist, um nicht ausgesperrt zu werden.",
"admin.config.oauth.github-enabled": "GitHub",
"admin.config.oauth.github-enabled.description": "GitHub Anmeldung erlaubt",
"admin.config.oauth.github-client-id": "GitHub Client-ID",
"admin.config.oauth.github-client-id.description": "Client-ID der GitHub OAuth-App",
"admin.config.oauth.github-client-secret": "GitHub Client-Secret",
"admin.config.oauth.github-client-secret.description": "Client-Secret der GitHub OAuth-App",
"admin.config.oauth.google-enabled": "Google",
"admin.config.oauth.google-enabled.description": "Google Anmeldung erlaubt",
"admin.config.oauth.google-client-id": "Google Client-ID",
"admin.config.oauth.google-client-id.description": "Client-ID der Google OAuth-App",
"admin.config.oauth.google-client-secret": "Google Client-Secret",
"admin.config.oauth.google-client-secret.description": "Client-Secret der Google OAuth-App",
"admin.config.oauth.microsoft-enabled": "Microsoft",
"admin.config.oauth.microsoft-enabled.description": "Microsoft Anmeldung erlaubt",
"admin.config.oauth.microsoft-tenant": "Microsoft Mandant",
"admin.config.oauth.microsoft-tenant.description": "Mandant-ID der Microsoft OAuth App\ncommon: Benutzer mit einem persönlichen Microsoft-Konto und einem Arbeits- oder Schulkonto von Microsoft Entra ID können sich in der Anwendung anmelden.\norganizations: Nur Benutzer mit Arbeits- oder Schulkonten von Microsoft Entra ID können sich in der Anwendung anmelden.\nconsumers: Nur Benutzer mit einem persönlichen Microsoft-Konto können sich in der Anwendung anmelden.\nDomänenname des Microsoft Entra Mandanten oder die Mandanten-ID im GUID-Format: Nur Benutzer eines bestimmten Microsoft Entra Mandanten (Verzeichnismitglieder mit einem Arbeits- oder Schulkonto oder Verzeichnis Gäste mit einem persönlichen Microsoft-Konto) können sich anmelden.",
"admin.config.oauth.microsoft-client-id": "Microsoft Client-ID",
"admin.config.oauth.microsoft-client-id.description": "Client-ID der Microsoft OAuth-App",
"admin.config.oauth.microsoft-client-secret": "Microsoft Client-Secret",
"admin.config.oauth.microsoft-client-secret.description": "Client-Secret der Microsoft OAuth-App",
"admin.config.oauth.discord-enabled": "Discord",
"admin.config.oauth.discord-enabled.description": "Discord Anmeldung erlaubt",
"admin.config.oauth.discord-limited-guild": "Discord Server-ID",
"admin.config.oauth.discord-limited-guild.description": "Die Anmeldung auf Benutzer in einem bestimmten Server beschränken. Leer lassen, um zu deaktivieren.",
"admin.config.oauth.discord-client-id": "Discord Client-ID",
"admin.config.oauth.discord-client-id.description": "Client-ID der Discord OAuth-App",
"admin.config.oauth.discord-client-secret": "Discord Client-Secret",
"admin.config.oauth.discord-client-secret.description": "Client-Secret der Discord OAuth-App",
"admin.config.oauth.oidc-enabled": "OpenID Connect",
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt",
"admin.config.oauth.oidc-discovery-uri": "OpenID Verbindung Discovery URL",
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App",
"admin.config.oauth.oidc-username-claim": "OpenID Connect Benutzername anfordern",
"admin.config.oauth.oidc-username-claim.description": "Benutzername im OpenID Token. Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
"admin.config.oauth.oidc-role-path.description": "Muss ein valider JMES-Pfad sein, der zu einem Array an Rollen führt. " + "Die Zugangsverwaltung über Rollen in OpenID Connect ist nur empfohlen, wenn kein anderer Identitätsprovider konfiguriert und die Anmeldung per Password deaktiviert ist. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
"admin.config.oauth.oidc-role-general-access": "OpenID Connect role for general access",
"admin.config.oauth.oidc-role-general-access.description": "Rolle für generellen Zugriff. Muss Teil der Rollen eines Benutzers sein, damit dieser sich anmelden kann. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
"admin.config.oauth.oidc-role-admin-access": "OpenID Connect role for admin access",
"admin.config.oauth.oidc-role-admin-access.description": "Rolle für administrativen Zugriff. Muss Teil der Rollen eines Benutzers sein, damit dieser auf das Administrator-Panel zugreifen kann. " + "Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
"admin.config.oauth.oidc-client-id": "OpenID Connect Client-ID",
"admin.config.oauth.oidc-client-id.description": "Client-ID der OpenID Connect OAuth-App",
"admin.config.oauth.oidc-client-secret": "OpenID Connect Client-Secret",
"admin.config.oauth.oidc-client-secret.description": "Client-Secret der OpenID Connect OAuth-App",
// 404
"404.description": "Ups, diese Seite existiert nicht.",
"404.button.home": "Zurück zur Startseite",
// error
"error.title": "Fehler",
"error.description": "Ups!",
"error.button.back": "Zurück",
"error.msg.default": "Etwas ist schief gelaufen.",
"error.msg.access_denied": "Du hast den Authentifizierungsprozess abgebrochen, bitte versuche es erneut.",
"error.msg.expired_token": "Der Authentifizierungsprozess hat zu lange gedauert, bitte versuche es erneut.",
"error.msg.invalid_token": "Interner Fehler",
"error.msg.no_user": "Der mit diesem {0} Konto verknüpfte Benutzer existiert nicht.",
"error.msg.no_email": "Kann die E-Mail-Adresse von dem Konto {0} nicht abrufen.",
"error.msg.already_linked": "Das Konto {0} ist bereits mit einem anderen Konto verknüpft.",
"error.msg.not_linked": "Das Konto {0} wurde noch nicht mit einem Konto verknüpft.",
"error.msg.unverified_account": "Dieses Konto {0} wurde noch nicht verifiziert, bitte versuche es nach der Verifikation erneut.",
"error.msg.user_not_allowed": "Du bist nicht berechtigt, dich anzumelden.",
"error.msg.cannot_get_user_info": "Deine Benutzerinformationen können nicht von diesem Konto {0} abgerufen werden.",
"error.param.provider_github": "GitHub",
"error.param.provider_google": "Google",
"error.param.provider_microsoft": "Microsoft",
"error.param.provider_discord": "Discord",
"error.param.provider_oidc": "OpenID Connect",
// Common translations
"common.button.save": "Speichern",
"common.button.create": "Erstellen",
@@ -309,8 +432,10 @@ export default {
"common.button.generate": "Generieren",
"common.button.done": "Fertig",
"common.text.link": "Link",
"common.text.navigate-to-link": "Link öffnen",
"common.text.or": "oder",
"common.button.go-back": "Zurück",
"common.button.go-home": "Zur Startseite",
"common.notify.copied": "Dein Link wurde in die Zwischenablage kopiert",
"common.success": "Erfolg",
"common.error": "Fehler",

View File

@@ -0,0 +1,449 @@
export default {
// Navbar
"navbar.upload": "Μεταφόρτωση",
"navbar.signin": "Είσοδος",
"navbar.home": "Αρχική",
"navbar.signup": "Εγγραφή",
"navbar.links.shares": "",
"navbar.links.reverse": "Αντίστροφος σύνδεσμος κοινής χρήσης",
"navbar.avatar.account": "Ο λογαριασμός μου",
"navbar.avatar.admin": "Διαχείριση",
"navbar.avatar.signout": "Αποσύνδεση",
// END navbar
// /
"home.title": "Μια πλατφόρμα κοινής χρήσης αρχείων <h>σε ιδιωτική εγκατάσταση</h>.",
"home.description": "Θέλετε πραγματικά να δώσετε τα προσωπικά σας αρχεία στο χέρι τρίτων όπως WeTransfer?",
"home.bullet.a.name": "Ιδιωτική εγκατάσταση",
"home.bullet.a.description": "Φιλοξενήστε το Pingvin Share στο δικό σας μηχάνημα.",
"home.bullet.b.name": "Απόρρητο",
"home.bullet.b.description": "Τα αρχεία σας είναι αρχεία σας και δεν πρέπει ποτέ να μπείτε στα χέρια τρίτων.",
"home.bullet.c.name": "Χωρίς όριο μεγέθους αρχείου",
"home.bullet.c.description": "Ανεβάστε όσο μεγάλα αρχεία θέλετε. Μόνο ο σκληρός σας δίσκος θα είναι το όριό σας.",
"home.button.start": "Ας αρχίσουμε",
"home.button.source": "Πηγαίος κώδικας",
// END /
// /auth/signin
"signin.title": "Καλώς ήρθατε ξανά",
"signin.description": "Δεν έχετε ακόμα λογαριασμό;",
"signin.button.signup": "Εγγραφή",
"signin.input.email-or-username": "E-mail ή όνομα χρήστη",
"signin.input.email-or-username.placeholder": "Το email ή το όνομα χρήστη",
"signin.input.password": "Κωδικόs πρόσβασης",
"signin.input.password.placeholder": "Ο κωδικός πρόσβασής σας",
"signin.button.submit": "Είσοδος",
"signIn.notify.totp-required.title": "Απαιτείται έλεγχος ταυτότητας δύο παραγόντων.",
"signIn.notify.totp-required.description": "Παρακαλώ εισάγετε τον κωδικό 2FA.",
"signIn.oauth.or": "Ή",
"signIn.oauth.signInWith": "Sign in with",
"signIn.oauth.github": "GitHub",
"signIn.oauth.google": "Google",
"signIn.oauth.microsoft": "Microsoft",
"signIn.oauth.discord": "Discord",
"signIn.oauth.oidc": "OpenID",
// END /auth/signin
// /auth/signup
"signup.title": "Δημιουργία λογαριασμού",
"signup.description": "Έχετε ήδη λογαριασμό;",
"signup.button.signin": "Είσοδος",
"signup.input.username": "Όνομα χρήστη",
"signup.input.username.placeholder": "Το όνομα χρήστη σας",
"signup.input.email": "E-mail",
"signup.input.email.placeholder": "Το email σας",
"signup.button.submit": "Ας ξεκινήσουμε",
// END /auth/signup
// /auth/totp
"totp.title": "Ταυτοποίηση TOTP",
"totp.button.signIn": "Είσοδος",
// END /auth/totp
// /auth/reset-password
"resetPassword.title": "Ξεχάσατε τον κωδικό σας;",
"resetPassword.description": "A message with a link to reset your password has been sent if the email exists.",
"resetPassword.notify.success": "Εισάγετε το email σας για επαναφορά κωδικού.",
"resetPassword.button.back": "Πίσω στη σελίδα εισόδου",
"resetPassword.text.resetPassword": "Επαναφορά κωδικού πρόσβασης",
"resetPassword.text.enterNewPassword": "Εισάγετε το νέο σας κωδικό",
"resetPassword.input.password": "Νέος κωδικός",
"resetPassword.notify.passwordReset": "Η επαναφορά του κωδικού πρόσβασης σας ολοκληρώθηκε με επιτυχία!",
// /account
"account.title": "Ο λογαριασμός μου",
"account.card.info.title": "Πληροφορίες λογαριασμού",
"account.card.info.username": "Όνομα χρήστη",
"account.card.info.email": "E-mail",
"account.notify.info.success": "Ο λογαριασμός ενημερώθηκε επιτυχώς!",
"account.card.password.title": "Κωδικόs πρόσβασης",
"account.card.password.old": "Παλιός κωδικός",
"account.card.password.new": "Νέος κωδικός",
"account.card.password.noPasswordSet": "Δεν έχετε ορίσει κωδικό πρόσβασης. Αν θέλετε να συνδεθείτε με email και κωδικό πρόσβασης, πρέπει να ορίσετε έναν κωδικό πρόσβασης.",
"account.notify.password.success": "Ο κωδικός πρόσβασης άλλαξε επιτυχώς",
"account.card.oauth.title": "Σύνδεση με λογαριασμό μέσων κοινωνικού δικτύου",
"account.card.oauth.github": "GitHub",
"account.card.oauth.google": "Google",
"account.card.oauth.microsoft": "Microsoft",
"account.card.oauth.discord": "Discord",
"account.card.oauth.oidc": "OpenID",
"account.card.oauth.link": "Σύνδεσμος",
"account.card.oauth.unlink": "Αποσύνδεση",
"account.card.oauth.unlinked": "Αποσυνδεδεμένο",
"account.modal.unlink.title": "Αποσύνδεση Λογαριασμού",
"account.modal.unlink.description": "Η αποσύνδεση των κοινωνικών λογαριασμών σας μπορεί να προκαλέσει απώλεια του λογαριασμού σας αν δε θυμάστε το όνομα χρήστη και τον κωδικό πρόσβασης.",
"account.notify.oauth.unlinked.success": "Επιτυχής αποσύνδεση",
"account.card.security.title": "Ασφάλεια",
"account.card.security.totp.enable.description": "Εισάγετε τον τρέχοντα κωδικό σας για να ξεκινήσετε την ενεργοποίηση του TOTP",
"account.card.security.totp.disable.description": "Εισάγετε τον τρέχοντα κωδικό πρόσβασης για να απενεργοποιήσετε το TOTP",
"account.card.security.totp.button.start": "Έναρξη",
"account.modal.totp.title": "Ενεργοποίηση TOTP",
"account.modal.totp.step1": "Βήμα 1: Προσθέστε τον έλεγχο ταυτότητας",
"account.modal.totp.step2": "Βήμα 2: Επικυρώστε τον κωδικό σας",
"account.modal.totp.enterManually": "Χειροκίνητη εισαγωγή",
"account.modal.totp.code": "Κώδικας",
"common.button.clickToCopy": "Κάνε κλικ για αντιγραφή",
"account.modal.totp.verify": "Επαλήθευση",
"account.notify.totp.disable": "Το TOTP απενεργοποιήθηκε επιτυχώς",
"account.notify.totp.enable": "Το TOTP ενεργοποιήθηκε επιτυχώς",
"account.card.language.title": "Γλώσσα",
"account.card.language.description": "Η μετάφραση της εφαρμογής γίνεται από την εθελοντές της κοινότητας. Μερικές γλώσσες μπορεί να είναι ελλιπείς.",
"account.card.color.title": "Συνδυασμός χρωμάτων",
// ThemeSwitcher.tsx
"account.theme.dark": "Σκοτεινό",
"account.theme.light": "Φωτεινό",
"account.theme.system": "Σύστημα",
"account.button.delete": "Διαγραφή Λογαριασμού",
"account.modal.delete.title": "Διαγραφή Λογαριασμού",
"account.modal.delete.description": "Θέλετε πραγματικά να διαγράψετε το λογαριασμό σας, συμπεριλαμβανομένων όλων των ενεργών μετοχών σας?",
// END /account
// /account/shares
"account.shares.title": "Οι κοινοποιήσεις μου",
"account.shares.title.empty": "Είναι κενό εδώ 👀",
"account.shares.description.empty": "Δεν διαμοιράζεστε τίποτα.",
"account.shares.button.create": "Δημιουργία",
"account.shares.info.title": "Πληροφορίες διαμοιρασμού",
"account.shares.table.id": "Αναγνωριστικό",
"account.shares.table.name": "Όνομα",
"account.shares.table.description": "Περιγραφή",
"account.shares.table.visitors": "Επισκέπτες",
"account.shares.table.expiresAt": "Λήξη",
"account.shares.table.createdAt": "Δημιουργήθηκε",
"account.shares.table.size": "Μέγεθος",
"account.shares.modal.share-informations": "Πληροφορίες διαμοιρασμού",
"account.shares.modal.share-link": "Κοινοποίηση συνδέσμου",
"account.shares.modal.delete.title": "Διαγραφή κοινοποίησης {share}",
"account.shares.modal.delete.description": "Θέλετε πραγματικά να διαγράψετε αυτό το διαμοιρασμό;",
// END /account/shares
// /account/reverseShares
"account.reverseShares.title": "Αντίστροφες κοινοποιήσεις",
"account.reverseShares.description": "Μια αντίστροφη κοινοποίηση σάς επιτρέπει να δημιουργήσετε μια μοναδική διεύθυνση URL που επιτρέπει σε εξωτερικούς χρήστες να δημιουργήσουν μια κοινή χρήση.",
"account.reverseShares.title.empty": "Είναι κενό εδώ 👀",
"account.reverseShares.description.empty": "Δεν έχετε καμία αντίστροφη μετοχή.",
// showCreateReverseShareModal.tsx
"account.reverseShares.modal.title": "Δημιουργία αντίστροφης κοινοποίησης",
"account.reverseShares.modal.expiration.label": "Λήξη",
"account.reverseShares.modal.expiration.minute-singular": "Λεπτό",
"account.reverseShares.modal.expiration.minute-plural": "Λεπτά",
"account.reverseShares.modal.expiration.hour-singular": "Ώρα",
"account.reverseShares.modal.expiration.hour-plural": "Ώρες",
"account.reverseShares.modal.expiration.day-singular": "Ημέρα",
"account.reverseShares.modal.expiration.day-plural": "Ημέρες",
"account.reverseShares.modal.expiration.week-singular": "Εβδομάδα",
"account.reverseShares.modal.expiration.week-plural": "Εβδομάδες",
"account.reverseShares.modal.expiration.month-singular": "Μήνας",
"account.reverseShares.modal.expiration.month-plural": "Μήνες",
"account.reverseShares.modal.expiration.year-singular": "Έτος",
"account.reverseShares.modal.expiration.year-plural": "Έτη",
"account.reverseShares.modal.max-size.label": "Μέγιστο μέγεθος κοινοποίησης",
"account.reverseShares.modal.send-email": "Αποστολή ειδοποιήσεων με email",
"account.reverseShares.modal.send-email.description": "Στείλτε μια ειδοποίηση μέσω ηλεκτρονικού ταχυδρομείου όταν δημιουργείται ένας διαμοιρασμός με αυτόν τον σύνδεσμο ανάστροφης κοινοποίησης.",
"account.reverseShares.modal.max-use.label": "Μέγιστες χρήσεις",
"account.reverseShares.modal.max-use.description": "Ο μέγιστος αριθμός που μπορεί να χρησιμοποιηθεί αυτό το URL για τη δημιουργία ενός διαμοιρασμού.",
"account.reverseShare.never-expires": "Αυτός ο αντίστροφος διαμοιρασμός δε λήγει.",
"account.reverseShare.expires-on": "Αυτός ο αντίστροφος διαμοιρασμός θα λήξει {expiration}.",
"account.reverseShares.table.no-shares": "Δε δημιουργήθηκαν κοινοποιήσεις ακόμα",
"account.reverseShares.table.count.singular": "διαμοιρασμός",
"account.reverseShares.table.count.plural": "διαμοιρασμοί",
"account.reverseShares.table.shares": "Διαμοιρασμοί",
"account.reverseShares.table.remaining": "Υπόλοιπες χρήσεις",
"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
// /admin
"admin.title": "Διαχείριση",
"admin.button.users": "Διαχείριση χρηστών",
"admin.button.shares": "Share management",
"admin.button.config": "Διαμόρφωση",
"admin.version": "Έκδοση",
// END /admin
// /admin/users
"admin.users.title": "Διαχείριση χρηστών",
"admin.users.table.username": "Όνομα χρήστη",
"admin.users.table.email": "E-mail",
"admin.users.table.admin": "Διαχειριστής",
"admin.users.edit.update.title": "Ενημέρωση χρήστη {username}",
"admin.users.edit.update.admin-privileges": "Δικαιώματα διαχειριστή",
"admin.users.edit.update.change-password.title": "Αλλαγή κωδικού πρόσβασής",
"admin.users.edit.update.change-password.field": "Νέος κωδικός πρόσβασης",
"admin.users.edit.update.change-password.button": "Αποθήκευση νέου κωδικού πρόσβασης",
"admin.users.edit.update.notify.password.success": "Ο κωδικός πρόσβασης άλλαξε επιτυχώς",
"admin.users.edit.delete.title": "Διαγραφή χρήστη {username}",
"admin.users.edit.delete.description": "Θέλετε να διαγράψετε το χρήστη και όλες τις κοινοποιήσεις του;",
// showCreateUserModal.tsx
"admin.users.modal.create.title": "Δημιουργία χρήστη",
"admin.users.modal.create.username": "Όνομα χρήστη",
"admin.users.modal.create.email": "E-mail",
"admin.users.modal.create.password": "Κωδικός πρόσβασης",
"admin.users.modal.create.manual-password": "Χειροκίνητος ορισμός κωδικού πρόσβασης",
"admin.users.modal.create.manual-password.description": "Εάν δεν είναι επιλεγμένο, ο χρήστης θα λάβει ένα email με ένα σύνδεσμο για να ορίσει τον κωδικό πρόσβασής του.",
"admin.users.modal.create.admin": "Δικαιώματα διαχειριστή",
"admin.users.modal.create.admin.description": "Αν ενεργοποιηθεί, ο χρήστης θα μπορεί να έχει πρόσβαση στον πίνακα διαχείρισης.",
// END /admin/users
// /admin/shares
"admin.shares.title": "Share management",
"admin.shares.table.id": "Share ID",
"admin.shares.table.username": "Creator",
"admin.shares.table.visitors": "Visitors",
"admin.shares.table.expires": "Expires At",
"admin.shares.edit.delete.title": "Delete share {id}",
"admin.shares.edit.delete.description": "Do you really want to delete this share?",
// END /admin/shares
// /upload
"upload.title": "Μεταφόρτωση",
"upload.notify.generic-error": "Παρουσιάστηκε σφάλμα κατά την ολοκλήρωση της κοινής χρήσης σας.",
"upload.notify.count-failed": "Τα αρχεία {count} απέτυχαν να μεταφορτώσουν. Δοκιμάστε ξανά.",
// Dropzone.tsx
"upload.dropzone.title": "Μεταφόρτωση αρχείων",
"upload.dropzone.description": "Σύρετε και αποθέστε αρχεία εδώ για να ξεκινήσει η κοινή χρήση σας. Μπορούμε να δεχτούμε μόνο αρχεία που είναι λιγότερο από {maxSize} συνολικά.",
"upload.dropzone.notify.file-too-big": "Τα αρχεία σας υπερβαίνουν το μέγιστο μέγεθος κοινής χρήσης του {maxSize}.",
// FileList.tsx
"upload.filelist.name": "Όνομα",
"upload.filelist.size": "Μέγεθος",
// showCreateUploadModal.tsx
"upload.modal.title": "Δημιουργία Κοινοποίησης",
"upload.modal.link.error.invalid": "Μπορεί να περιέχει μόνο γράμματα, αριθμούς, κάτω παύλες και παύλες",
"upload.modal.link.error.taken": "Αυτός ο σύνδεσμος χρησιμοποιείται ήδη",
"upload.modal.not-signed-in": "Δεν είστε συνδεδεμένος/η",
"upload.modal.not-signed-in-description": "Δεν θα μπορείτε να διαγράψετε την κοινή χρήση σας χειροκίνητα και να δείτε την αρίθμηση επισκεπτών.",
"upload.modal.expires.never": "ποτέ",
"upload.modal.expires.never-long": "Χωρίς λήξη",
"upload.modal.expires.error.too-long": "Η λήξη υπερβαίνει τη μέγιστη ημερομηνία λήξης {max}.",
"upload.modal.link.label": "Σύνδεσμος",
"upload.modal.expires.label": "Λήξη",
"upload.modal.expires.minute-singular": "Λεπτό",
"upload.modal.expires.minute-plural": "Λεπτά",
"upload.modal.expires.hour-singular": "Ώρα",
"upload.modal.expires.hour-plural": "Ώρες",
"upload.modal.expires.day-singular": "Ημέρα",
"upload.modal.expires.day-plural": "Ημέρες",
"upload.modal.expires.week-singular": "Εβδομάδα",
"upload.modal.expires.week-plural": "Εβδομάδες",
"upload.modal.expires.month-singular": "Μήνας",
"upload.modal.expires.month-plural": "Μήνες",
"upload.modal.expires.year-singular": "Έτος",
"upload.modal.expires.year-plural": "Έτη",
"upload.modal.accordion.name-and-description.title": "Name and description",
"upload.modal.accordion.name-and-description.name.placeholder": "Name",
"upload.modal.accordion.name-and-description.description.placeholder": "Note for the recipients of this share",
"upload.modal.accordion.email.title": "Αποδέκτες email",
"upload.modal.accordion.email.placeholder": "Εισάγετε αποδέκτες email",
"upload.modal.accordion.email.invalid-email": "Μη έγκυρη διεύθυνση e-mail",
"upload.modal.accordion.security.title": "Επιλογές ασφαλείας",
"upload.modal.accordion.security.password.label": "Προστασία με κωδικό πρόσβασης",
"upload.modal.accordion.security.password.placeholder": "Χωρίς Κωδικό",
"upload.modal.accordion.security.max-views.label": "Μέγιστος αριθμός εμφανίσεων",
"upload.modal.accordion.security.max-views.placeholder": "Χωρίς όριο",
// showCompletedUploadModal.tsx
"upload.modal.completed.never-expires": "Αυτός ο διαμοιρασμός δεν λήγει.",
"upload.modal.completed.expires-on": "Αυτός ο διαμοιρασμός θα λήξει {expiration}.",
"upload.modal.completed.share-ready": "Κοινοποίηση έτοιμου",
// END /upload
// /share/[id]
"share.title": "Διαμοιρασμός {shareId}",
"share.description": "Σας προωθώ αρχεία προς κοινοποίηση.",
"share.error.visitor-limit-exceeded.title": "Υπέρβαση ορίου επισκέπτη",
"share.error.visitor-limit-exceeded.description": "Ξεπεράστηκε το όριο επισκεπτών σε αυτή την κοινοποίηση.",
"share.error.removed.title": "Κοινοποίηση αφαιρέθηκε",
"share.error.not-found.title": "Η κοινοποίηση δε βρέθηκε",
"share.error.not-found.description": "Η κοινοποίηση που ψάχνετε δεν υπάρχει.",
"share.modal.password.title": "Απαιτείται κωδικός",
"share.modal.password.description": "Για να αποκτήσετε πρόσβαση σε αυτή την κοινοποίηση εισάγετε τον κωδικό πρόσβασης.",
"share.modal.password": "Κωδικός πρόσβασης",
"share.modal.error.invalid-password": "Μη έγκυρος κωδικός πρόσβασης",
"share.button.download-all": "Λήψη όλων",
"share.notify.download-all-preparing": "Αυτός ο διαμοιρασμός βρίσκεται σε επεξεργασία. Προσπαθήστε ξανά σε λίγο.",
"share.modal.file-link": "Σύνδεσμος αρχείου",
"share.table.name": "Όνομα",
"share.table.size": "Μέγεθος",
"share.modal.file-preview.error.not-supported.title": "Η προεπισκόπηση δεν υποστηρίζεται",
"share.modal.file-preview.error.not-supported.description": "Η προεπισκόπηση για αυτό τον τύπο αρχείου δεν υποστηρίζεται. Παρακαλώ κατεβάστε το αρχείο για να το δείτε.",
// END /share/[id]
// /share/[id]/edit
"share.edit.title": "Ενημέρωση {shareId}",
"share.edit.append-upload": "Προσθήκη αρχείου",
"share.edit.notify.generic-error": "Παρουσιάστηκε σφάλμα κατά την ολοκλήρωση του διαμοιρασμού.",
"share.edit.notify.save-success": "Ο διαμοιρασμός ενημερώθηκε επιτυχώς",
// END /share/[id]/edit
// /admin/config
"admin.config.title": "Διαμόρφωση",
"admin.config.category.general": "Γενικά",
"admin.config.category.share": "Διαμοιρασμός",
"admin.config.category.email": "Email",
"admin.config.category.smtp": "SMTP",
"admin.config.category.oauth": "Σύνδεση με λογαριασμό μέσων κοινωνικού δικτύου",
"admin.config.general.app-name": "Όνομα εφαρμογής",
"admin.config.general.app-name.description": "Ονομασία της εφαρμογής",
"admin.config.general.app-url": "URL Εφαρμογής",
"admin.config.general.app-url.description": "Η διεύθυνση URL όπου το Pingvin Share είναι διαθέσιμο",
"admin.config.general.show-home-page": "Εμφάνιση αρχικής σελίδας",
"admin.config.general.show-home-page.description": "Εάν θα εμφανίζεται η αρχική σελίδα",
"admin.config.general.session-duration": "Session Duration",
"admin.config.general.session-duration.description": "Time in hours after which a user must log in again (default: 3 months).",
"admin.config.general.logo": "Λογότυπο",
"admin.config.general.logo.description": "Αλλάξτε το λογότυπό σας ανεβάζοντας μια νέα εικόνα. Η εικόνα πρέπει να είναι PNG και αναλογία 1:1.",
"admin.config.general.logo.placeholder": "Επιλέξτε εικόνα",
"admin.config.email.enable-share-email-recipients": "Ενεργοποίηση κοινής χρήσης μέσω email",
"admin.config.email.enable-share-email-recipients.description": "Εάν θα ενεργοποιηθεί η κοινή χρήση μέσω email. Δυνατή μόνον με την ενεργοποίηση SMTP.",
"admin.config.email.share-recipients-subject": "Θέμα στο email διαμοιρασμού",
"admin.config.email.share-recipients-subject.description": "Το θέμα του email διαμοιρασμού που θα φτάσει στον παραλήπτη.",
"admin.config.email.share-recipients-message": "Το θέμα του email για τον διαμοιρασμό που θα φτάσει στον παραλήπτη ",
"admin.config.email.share-recipients-message.description": "Μήνυμα που αποστέλλεται στους παραλήπτες κοινής χρήσης. Διαθέσιμες μεταβλητές:\n {creator} - Το όνομα χρήστη του δημιουργού της κοινής χρήσης\n {shareUrl} - Η διεύθυνση URL της κοινής χρήσης\n {desc} - Η περιγραφή της κοινής χρήσης\n {expires} - Η ημερομηνία λήξης της κοινής χρήσης\n Οι μεταβλητές θα αντικατασταθούν με την πραγματική τιμή.",
"admin.config.email.reverse-share-subject": "Θέμα email αντίστροφου διαμοιρασμού",
"admin.config.email.reverse-share-subject.description": "Θέμα του ηλεκτρονικού ταχυδρομείου που αποστέλλεται όταν κάποιος δημιούργησε έναν αντίστροφο διαμοιρασμό.",
"admin.config.email.reverse-share-message": "Μήνυμα email αντίστροφου διαμοιρασμού",
"admin.config.email.reverse-share-message.description": "Μήνυμα που αποστέλλεται όταν κάποιος δημιουργεί έναν σύνδεσμο αντίστροφου διαμοιρασμού. Το {shareUrl} θα αντικατασταθεί με το όνομα του δημιουργού και τη διεύθυνση URL κοινής χρήσης.",
"admin.config.email.reset-password-subject": "Θέμα μηνύματος επαναφοράς κωδικού πρόσβασης",
"admin.config.email.reset-password-subject.description": "Θέμα του email που αποστέλλεται όταν ένας χρήστης ζητήσει επαναφορά κωδικού πρόσβασης.",
"admin.config.email.reset-password-message": "Κείμενο μηνύματος επαναφοράς κωδικού πρόσβασης",
"admin.config.email.reset-password-message.description": "Μήνυμα που αποστέλλεται όταν ένας χρήστης ζητά επαναφορά κωδικού πρόσβασης. Το {url} θα αντικατασταθεί με τη διεύθυνση URL επαναφοράς κωδικού πρόσβασης.",
"admin.config.email.invite-subject": "Θέμα μηνύματος πρόσκλησης",
"admin.config.email.invite-subject.description": "Θέμα του email που αποστέλλεται όταν ένας διαχειριστής προσκαλεί έναν χρήστη.",
"admin.config.email.invite-message": "Μήνυμα μηνύματος πρόσκλησης",
"admin.config.email.invite-message.description": "Το μήνυμα που αποστέλλεται όταν ένας διαχειριστής προσκαλεί έναν χρήστη. Το {url} θα αντικατασταθεί με το URL πρόσκλησης και το {password} με τον κωδικό πρόσβασης.",
"admin.config.share.allow-registration": "Να επιτρέπεται η εγγραφή",
"admin.config.share.allow-registration.description": "Αν επιτρέπεται η εγγραφή",
"admin.config.share.allow-unauthenticated-shares": "Επιτρέψτε κοινές χρήσεις χωρίς έλεγχο ταυτότητας",
"admin.config.share.allow-unauthenticated-shares.description": "Εάν οι χρήστες χωρίς έλεγχο ταυτότητας μπορούν να δημιουργήσουν κοινόχρηστα στοιχεία",
"admin.config.share.max-expiration": "Μέγιστη λήξη",
"admin.config.share.max-expiration.description": "Μέγιστη λήξη κοινής χρήσης σε ώρες. Ορίστε το 0 για να επιτρέψετε απεριόριστη λήξη.",
"admin.config.share.max-size": "Μέγιστο μέγεθος",
"admin.config.share.max-size.description": "Μέγιστο μέγεθος κοινοποίησης σε bytes",
"admin.config.share.zip-compression-level": "Βαθμός συμπίεσης ZIP",
"admin.config.share.zip-compression-level.description": "Προσαρμόστε το βαθμό συμπίεσης για να εξισορροπηθεί το μέγεθος του αρχείου και η ταχύτητα επεξεργασίας. Έγκυρες τιμές κυμαίνονται από 0 έως 9, με 0 χωρίς συμπίεση και 9 μέγιστη συμπίεση.",
"admin.config.share.chunk-size": "Μέγεθος κομματιών",
"admin.config.share.chunk-size.description": "Προσαρμόστε το μέγεθος κομματιών (σε bytes) για να εξισορροπήσετε την αποδοτικότητα και την αξιοπιστία του συστήματος σύμφωνα με τη σύνδεσή σας στο διαδίκτυο. Μικρότερα κομμάτια μπορούν να βελτιώσουν τα ποσοστά επιτυχίας σε ασταθείς συνδέσεις, ενώ μεγαλύτερα κομμάτια επιταχύνουν τις μεταφορτώσεις σε σταθερές συνδέσεις.",
"admin.config.share.auto-open-share-modal": "Auto open create share modal",
"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.enabled": "Ενεργοποιημένο",
"admin.config.smtp.enabled.description": "Εάν η λειτουργία SMTP είναι ενεργοποιημένη. Ενεργοποιήστε τη μόνον όταν ορίσετε σωστά τις παραμέτρους που ακολουθούν.",
"admin.config.smtp.host": "Εξυπηρετητής",
"admin.config.smtp.host.description": "SMTP εξυπηρετητής",
"admin.config.smtp.port": "Θύρα",
"admin.config.smtp.port.description": "SMTP θύρα",
"admin.config.smtp.email": "Email",
"admin.config.smtp.email.description": "Διεύθυνση email από όπου αποστέλλονται τα μηνύματα",
"admin.config.smtp.username": "Όνομα χρήστη",
"admin.config.smtp.username.description": "Όνομα χρήστη στον SMTP εξυπηρετητή",
"admin.config.smtp.password": "Κωδικός πρόσβασης",
"admin.config.smtp.password.description": "Κωδικός πρόσβασης στον εξυπηρετητή SMTP",
"admin.config.smtp.button.test": "Αποστολή δοκιμαστικού email",
"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": "Disable password login",
"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.description": "Αν είναι ενεργοποιημένη η σύνδεση GitHub",
"admin.config.oauth.github-client-id": "GitHub Client ID",
"admin.config.oauth.github-client-id.description": "Αναγνωριστικό πελάτη της εφαρμογής GitHub OAuth",
"admin.config.oauth.github-client-secret": "GitHub Client secret",
"admin.config.oauth.github-client-secret.description": "Client secret of the GitHub OAuth app",
"admin.config.oauth.google-enabled": "Google",
"admin.config.oauth.google-enabled.description": "Αν θα είναι ενεργοποιημένη η σύνδεση Google",
"admin.config.oauth.google-client-id": "Αναγνωριστικό Πελάτη Google",
"admin.config.oauth.google-client-id.description": "Αναγνωριστικό πελάτη της εφαρμογής Google OAuth",
"admin.config.oauth.google-client-secret": "Google κωδικός",
"admin.config.oauth.google-client-secret.description": "Κωδικός της εφαρμογής Google OAuth",
"admin.config.oauth.microsoft-enabled": "Microsoft",
"admin.config.oauth.microsoft-enabled.description": "Αν είναι ενεργοποιημένη η σύνδεση της Microsoft",
"admin.config.oauth.microsoft-tenant": "Αναγνωριστικό Microsoft",
"admin.config.oauth.microsoft-tenant.description": "Αναγνωριστικό για την εφαρμογή Microsoft OAuth\nΚοινή: Οι χρήστες με προσωπικό λογαριασμό Microsoft και λογαριασμό εργασίας ή σχολείου από το Microsoft Entra ID μπορούν να συνδεθούν στην εφαρμογή.\nΟργανισμοί: Μόνο χρήστες με λογαριασμούς εργασίας ή σχολείου από το Microsoft Entra ID μπορούν να συνδεθούν στην εφαρμογή.\nΚαταναλωτές: Μόνο οι χρήστες με προσωπικό λογαριασμό Microsoft μπορούν να συνδεθούν στην εφαρμογή.\nΜε όνομα τομέα του μισθωτή Microsoft Entra ή το αναγνωριστικό μισθωτή σε μορφή GUID: Μόνο χρήστες από έναν συγκεκριμένο μισθωτή της Microsoft Entra (μέλη καταλόγου με λογαριασμό εργασίας ή σχολείου ή επισκέπτες καταλόγου με προσωπικό λογαριασμό Microsoft) μπορούν να συνδεθούν στην εφαρμογή.",
"admin.config.oauth.microsoft-client-id": "Αναγνωριστικό πελάτη Microsoft",
"admin.config.oauth.microsoft-client-id.description": "Αναγνωριστικό πελάτη της εφαρμογής Microsoft OAuth",
"admin.config.oauth.microsoft-client-secret": "Μυστικό πελάτη Microsoft",
"admin.config.oauth.microsoft-client-secret.description": "Μυστικό πελάτη της εφαρμογής Microsoft OAuth",
"admin.config.oauth.discord-enabled": "Discord",
"admin.config.oauth.discord-enabled.description": "Αν είναι ενεργοποιημένη η σύνδεση στο Discord",
"admin.config.oauth.discord-limited-guild": "Αναγνωριστικό διακομιστή περιορισμένης ισχύος Discord",
"admin.config.oauth.discord-limited-guild.description": "Περιορισμός σύνδεσης σε χρήστες σε ένα συγκεκριμένο διακομιστή. Αφήστε κενό για να απενεργοποιήσετε.",
"admin.config.oauth.discord-client-id": "Αναγνωριστικό Πελάτη Discord",
"admin.config.oauth.discord-client-id.description": "Αναγνωριστικό πελάτη της εφαρμογής Discord OAuth",
"admin.config.oauth.discord-client-secret": "Μυστικό πελάτη Discord",
"admin.config.oauth.discord-client-secret.description": "Μυστικό πελάτη της εφαρμογής Discord OAuth",
"admin.config.oauth.oidc-enabled": "Σύνδεση OpenID",
"admin.config.oauth.oidc-enabled.description": "Αν είναι ενεργοποιημένη η σύνδεση OpenID",
"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-username-claim": "OpenID Connect username claim",
"admin.config.oauth.oidc-username-claim.description": "Username claim in OpenID Connect ID token. Αφήστε κενό αν δε γνωρίζετε για αυτή τη ρύθμιση",
"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 users 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 users 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.description": "Client ID of the OpenID Connect OAuth app",
"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",
// 404
"404.description": "Ουπς. Αυτή η σελίδα δεν υπάρχει.",
"404.button.home": "Πήγαινέ με πίσω",
// error
"error.title": "Σφάλμα",
"error.description": "Ωχ!",
"error.button.back": "Πάμε πίσω",
"error.msg.default": "Κάτι πήγε στραβά.",
"error.msg.access_denied": "Ακυρώσατε τη διαδικασία ελέγχου ταυτότητας, παρακαλώ προσπαθήστε ξανά.",
"error.msg.expired_token": "Η διαδικασία ελέγχου ταυτότητας διήρκεσε πολύ, παρακαλώ προσπαθήστε ξανά.",
"error.msg.invalid_token": "Εσωτερικό σφάλμα",
"error.msg.no_user": "Ο χρήστης που συνδέεται με αυτόν τον λογαριασμό {0} δεν υπάρχει.",
"error.msg.no_email": "Δεν είναι δυνατή η λήψη διεύθυνσης ηλεκτρονικού ταχυδρομείου για αυτόν τον λογαριασμό {0}.",
"error.msg.already_linked": "Αυτός ο λογαριασμός {0} είναι ήδη συνδεδεμένος με άλλο λογαριασμό.",
"error.msg.not_linked": "Αυτός ο λογαριασμός {0} δεν έχει συνδεθεί με κανένα λογαριασμό ακόμα.",
"error.msg.unverified_account": "Αυτός ο λογαριασμός {0} δεν έχει επαληθευτεί, παρακαλώ προσπαθήστε ξανά μετά την επαλήθευση.",
"error.msg.user_not_allowed": "Δεν σας επιτρέπεται η σύνδεση.",
"error.msg.cannot_get_user_info": "Δεν είναι δυνατή η λήψη των πληροφοριών χρήστη από αυτόν τον λογαριασμό {0}.",
"error.param.provider_github": "GitHub",
"error.param.provider_google": "Google",
"error.param.provider_microsoft": "Microsoft",
"error.param.provider_discord": "Discord",
"error.param.provider_oidc": "Σύνδεση OpenID",
// Common translations
"common.button.save": "Αποθήκευση",
"common.button.create": "Δημιουργία",
"common.button.submit": "Υποβολή",
"common.button.delete": "Διαγραφή",
"common.button.cancel": "Ακύρωση",
"common.button.confirm": "Επιβεβαίωση",
"common.button.disable": "Απενεργοποίηση",
"common.button.share": "Διαμοιρασμός",
"common.button.generate": "Δημιουργία",
"common.button.done": "Ολοκληρώθηκε",
"common.text.link": "Σύνδεσμος",
"common.text.navigate-to-link": "Μεταβείτε στο σύνδεσμο",
"common.text.or": "ή",
"common.button.go-back": "Επιστροφή",
"common.button.go-home": "Μετάβαση στην αρχική",
"common.notify.copied": "Ο σύνδεσμος σας αντιγράφηκε στο πρόχειρο",
"common.success": "Επιτυχία",
"common.error": "Σφάλμα",
"common.error.unknown": "Προέκυψε άγνωστο σφάλμα",
"common.error.invalid-email": "Μη έγκυρη διεύθυνση e-mail",
"common.error.too-short": "Πρέπει να αποτελείται τουλάχιστον {length} χαρακτήρες",
"common.error.too-long": "Πρέπει να αποτελείται το πολύ από {length} χαρακτήρες",
"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