Compare commits

...

177 Commits

Author SHA1 Message Date
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
Elias Schneider
b088a5ef2a release: 0.18.2 2023-10-09 11:20:06 +02:00
Elias Schneider
c502cd58db 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)
2023-10-09 11:19:48 +02:00
Elias Schneider
97e7d7190d fix: memory leak while downloading large files 2023-10-09 11:14:51 +02:00
Elias Schneider
38919003e9 fix: disable image optimizations for logo to prevent caching issues with custom logos 2023-10-09 10:40:55 +02:00
Elias Schneider
f15a8dc277 chore(translations): add files for Japanese 2023-10-06 09:21:14 +02:00
Elias Schneider
92927b1373 release: 0.18.1 2023-09-22 11:31:03 +02:00
Elias Schneider
6a4108ed61 fix: permission changes of docker container brakes existing installations 2023-09-22 11:30:53 +02:00
Elias Schneider
c9f1be2faf release: 0.18.0 2023-09-21 16:24:07 +02:00
Elias Schneider
57be6945f2 chore(ci/cd): cache Docker build 2023-09-21 16:09:23 +02:00
Elias Schneider
82abe52ea5 chore(translations): update translations via Crowdin (#253)
* New translations en-us.ts (German)

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

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

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

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

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

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

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

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

* New translations en-us.ts (Dutch, Belgium)
2023-09-21 16:05:42 +02:00
KdF
6fa7af7905 fix(docker): Updated to newest version of alpine linux and fixed missing dependencies (#255)
* Updated docker file

* yes

* Update Dockerfile

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

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-09-21 16:04:02 +02:00
Elias Schneider
13e7a30bb9 feat: show upload modal on file drop 2023-09-21 15:59:55 +02:00
Elias Schneider
955af04e32 chore(translations): add Dutch files 2023-09-18 17:48:38 +02:00
Elias Schneider
035e67f759 chore(translations): update translations via Crowdin (#250)
* New translations en-US.ts (Serbian (Cyrillic))

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

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

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

* New translations en-US.ts (Serbian (Cyrillic))
2023-09-18 11:23:55 +02:00
Elias Schneider
167ec782ef New translations en-US.ts (Spanish) (#248) 2023-09-12 11:47:12 +02:00
Elias Schneider
743c33475f chore(translations): add Serbian files 2023-09-12 11:45:20 +02:00
adriadam10
3f1d3b7833 Run docker container as non root user (#242)
* Run docker container as non root user

* Pass UID and GID as a variable + alpine-based image

* change apt-get to apk

* chore: remove unnecessary packages from Dockerfile

* chore: remove unnecessary `chown`

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-09-11 16:14:42 +02:00
Elias Schneider
3d76e41cd8 chore(translations): update translations via Crowdin (#239)
* New translations en-US.ts (Portuguese, Brazilian)

* New translations en-US.ts (French)
2023-09-09 20:56:57 +02:00
Elias Schneider
e9efbc17bc fix: nextjs proxy warning 2023-09-05 14:58:38 +02:00
Elias Schneider
307d176430 release: 0.17.5 2023-09-03 22:14:34 +02:00
Elias Schneider
7e24ba9721 chore(translations): update translations via Crowdin (#238)
* 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 (Russian)

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

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

* New translations en-US.ts (Thai)
2023-09-03 22:14:11 +02:00
Elias Schneider
f9774d82d8 refactor: run formatter 2023-09-03 22:13:57 +02:00
Elias Schneider
7647a9f620 fix: missing translation 2023-09-03 22:09:55 +02:00
Elias Schneider
d4e8d4f58b fix: autocomplete on create share modal 2023-09-03 22:07:40 +02:00
Elias Schneider
4df8dea5cc chore(translations): update translations via Crowdin (#232)
* New translations en-US.ts (Danish)

* New translations en-US.ts (French)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (German)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Russian)

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

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

* New translations en-US.ts (Thai)
2023-08-17 15:02:22 +02:00
Elias Schneider
84aa100f84 chore: formatter ignore translations 2023-08-17 15:00:57 +02:00
iUnstable0
bddb87b9b3 feat(localization): Added thai language (#231)
* feat(localization): Added Thai translation

* Formatted

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-08-17 14:54:26 +02:00
Elias Schneider
18c10c0ac6 New translations en-US.ts (Danish) (#229) 2023-08-17 14:51:38 +02:00
Elias Schneider
f02e2979c4 refactor: run formatter 2023-08-17 14:47:58 +02:00
Elias Schneider
7b34cb14cb New translations en-US.ts (German) (#223) 2023-08-07 08:43:17 +02:00
Elias Schneider
019ef090ac chore(translations): update translations via Crowdin (#222)
* New translations en-US.ts (Portuguese, Brazilian)

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

* New translations en-US.ts (Russian)

* New translations en-US.ts (German)

* New translations en-US.ts (Russian)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Russian)
2023-08-01 12:50:41 +02:00
Elias Schneider
7304b54125 release: 0.17.4 2023-08-01 12:37:47 +02:00
Elias Schneider
ea0d5216e8 fix: redirection to localhost:3000 2023-08-01 12:35:37 +02:00
Elias Schneider
62deb6c152 release: 0.17.3 2023-07-31 16:38:58 +02:00
Elias Schneider
9ba2b4c82c fix: logo doesn't get loaded correctly 2023-07-31 16:38:29 +02:00
Elias Schneider
a47d080657 fix: share expiration never doesn't work if using another language than English 2023-07-31 16:34:24 +02:00
176 changed files with 19996 additions and 12044 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,4 +1,4 @@
name: Create Docker Image
name: Build and Push Docker Image
on:
release:
@@ -10,15 +10,25 @@ jobs:
steps:
- name: checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: login to docker registry
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build the image
run: |
docker buildx build --push \
--tag stonith404/pingvin-share:latest \
--tag stonith404/pingvin-share:${{ github.ref_name }} \
--platform linux/amd64,linux/arm64 .
- name: Login to Docker registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

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 +0,0 @@
/backend/src/constants.ts

View File

@@ -1,3 +1,266 @@
## [0.26.0](https://github.com/stonith404/pingvin-share/compare/v0.25.0...v0.26.0) (2024-07-03)
### Features
* **backend:** Make session duration configurable ([#512](https://github.com/stonith404/pingvin-share/issues/512)) ([367f804](https://github.com/stonith404/pingvin-share/commit/367f804a494c85b4caf879d51982339fb6b86ba1)), closes [#507](https://github.com/stonith404/pingvin-share/issues/507)
### Bug Fixes
* **oauth:** provider username is ignored when signing up using OAuth ([#511](https://github.com/stonith404/pingvin-share/issues/511)) ([31366d9](https://github.com/stonith404/pingvin-share/commit/31366d961f5827c200038b65ec9de5d4ddc8b898)), closes [#505](https://github.com/stonith404/pingvin-share/issues/505)
* set max age of access token cookie to 15 minutes ([2dac385](https://github.com/stonith404/pingvin-share/commit/2dac38560b6c54b6e7676dcd4682bfa57973292f))
## [0.25.0](https://github.com/stonith404/pingvin-share/compare/v0.24.2...v0.25.0) (2024-06-10)
### Features
* add auto open share modal config for global. ([#474](https://github.com/stonith404/pingvin-share/issues/474)) ([4fd2903](https://github.com/stonith404/pingvin-share/commit/4fd29037a08dbe505bdd8cf20f6f114cbade8483))
* **frontend:** locale for dates and tooltip for copy link button ([#492](https://github.com/stonith404/pingvin-share/issues/492)) ([1c7832a](https://github.com/stonith404/pingvin-share/commit/1c7832ad1fb445fd1dbe1c111be5a331eaa4b797))
### Bug Fixes
* share size not displayed on my shares page ([c0cc16f](https://github.com/stonith404/pingvin-share/commit/c0cc16fa430bc64afb024c19d5faf24456bd417c))
## [0.24.2](https://github.com/stonith404/pingvin-share/compare/v0.24.1...v0.24.2) (2024-05-22)
### Bug Fixes
* admin couldn't delete shares created by anonymous users ([7afda85](https://github.com/stonith404/pingvin-share/commit/7afda85f03d410a6c611860d0c3fb2b88a2e3679))
* whitespace in title on homepage ([74cd520](https://github.com/stonith404/pingvin-share/commit/74cd520cb8c4ea87822ab6d54c0bf010455f401b))
## [0.24.1](https://github.com/stonith404/pingvin-share/compare/v0.24.0...v0.24.1) (2024-05-04)
### Bug Fixes
* error on admin share management page if a share was created by an anonymous user ([c999df1](https://github.com/stonith404/pingvin-share/commit/c999df15e04a927f6e952db3c807b9591fb14894))
## [0.24.0](https://github.com/stonith404/pingvin-share/compare/v0.23.1...v0.24.0) (2024-05-04)
### Features
* add admin-exclusive share-management page ([#461](https://github.com/stonith404/pingvin-share/issues/461)) ([3b1c9f1](https://github.com/stonith404/pingvin-share/commit/3b1c9f1efb7d02469e92537a2d1378b6cb412878))
* add name property to share ([#462](https://github.com/stonith404/pingvin-share/issues/462)) ([b717663](https://github.com/stonith404/pingvin-share/commit/b717663b5c3a4a98e361e7e39b680f4852537c59))
## [0.23.1](https://github.com/stonith404/pingvin-share/compare/v0.23.0...v0.23.1) (2024-04-05)
### Bug Fixes
* **backend:** crash on unhandled promise rejections ([1da4fee](https://github.com/stonith404/pingvin-share/commit/1da4feeb895a13d0a0ae754bd716a84e8186d081))
* changing the chunk size needed an app restart ([24e100b](https://github.com/stonith404/pingvin-share/commit/24e100bd7be8bf20778bdf2767aa35cae8d7e502))
* disable js execution on raw file view ([9d1a12b](https://github.com/stonith404/pingvin-share/commit/9d1a12b0d1812214f1fe6fa56e3848091ce4945c))
* incorrect layout on 404 page ([3c5e0ad](https://github.com/stonith404/pingvin-share/commit/3c5e0ad5134ee2d405ac420152b5825102f65bfc))
* normal shares were added to the previous reverse share ([3972589](https://github.com/stonith404/pingvin-share/commit/3972589f76519b03074d916fb2460c795b1f0737))
* redirect vulnerability on error, sign in and totp page ([384fd19](https://github.com/stonith404/pingvin-share/commit/384fd19203b63eeb4b952f83a9e1eaab1b19b90d))
## [0.23.0](https://github.com/stonith404/pingvin-share/compare/v0.22.2...v0.23.0) (2024-04-04)
### Features
* add config variable to adjust chunk size ([0bfbaea](https://github.com/stonith404/pingvin-share/commit/0bfbaea49aad0c695fee6558c89c661687912e4f))
### Bug Fixes
* delete share files if user gets deleted ([e71f6cd](https://github.com/stonith404/pingvin-share/commit/e71f6cd1598ed87366074398042a6b88675587ca))
* error in logs if "allow unauthenticated shares" is enabled ([c6d8188](https://github.com/stonith404/pingvin-share/commit/c6d8188e4e33ba682551a3ca79205ff5a6d7ead5))
* memory leak while uploading files by disabling base64 encoding of chunks ([7a15fbb](https://github.com/stonith404/pingvin-share/commit/7a15fbb4651c2fee32fb4c1ee2c9d7f12323feb0))
## [0.22.2](https://github.com/stonith404/pingvin-share/compare/v0.22.1...v0.22.2) (2024-02-29)
### Bug Fixes
* extend access token cookie expiration ([013b988](https://github.com/stonith404/pingvin-share/commit/013b9886af5629b2ead6000b962267afc761c612))
* reduce refresh access token calls ([1aa3d8e](https://github.com/stonith404/pingvin-share/commit/1aa3d8e5e89b3696cc9554f41e9ce13806dde406))
* replace Nginx with Caddy to fix "premature close" error while downloading larger files ([43bff91](https://github.com/stonith404/pingvin-share/commit/43bff91db2ba4ec68d76e601f7bc42cb7a506bc5))
## [0.22.1](https://github.com/stonith404/pingvin-share/compare/v0.22.0...v0.22.1) (2024-02-18)
### Bug Fixes
* back links on error modals ([f52dffd](https://github.com/stonith404/pingvin-share/commit/f52dffdaac5a893804525913943f3f4f99b7c55a))
* prevent zoom on input field click on mobile ([9c734ec](https://github.com/stonith404/pingvin-share/commit/9c734ec439aeaeebe172caa41bf531e6d8b3fac3))
* replace middleware backend url with local backend url ([76df6f6](https://github.com/stonith404/pingvin-share/commit/76df6f66d965dd751146468abfafb0c6acd46310))
* user `id` and `totpVerified` can't be changed by user ([e663da4](https://github.com/stonith404/pingvin-share/commit/e663da45b1d15f5e6e33118e6a28e1504688034c))
* user enumaration on forgot password page ([64515d7](https://github.com/stonith404/pingvin-share/commit/64515d77cfc116a243d78610395ccc383ba62940))
## [0.22.0](https://github.com/stonith404/pingvin-share/compare/v0.21.5...v0.22.0) (2024-02-04)
### Bug Fixes
* **translations:** typo in string ([c189cd9](https://github.com/stonith404/pingvin-share/commit/c189cd97a502cee8ea79e5187d9288d636d4983c))
## [0.21.5](https://github.com/stonith404/pingvin-share/compare/v0.21.4...v0.21.5) (2024-01-14)
### Bug Fixes
* password can be changed with wrong password ([0ccb836](https://github.com/stonith404/pingvin-share/commit/0ccb8364448d27ea07c8b11972ff454d610893c6))
## [0.21.4](https://github.com/stonith404/pingvin-share/compare/v0.21.3...v0.21.4) (2024-01-09)
### Features
* **frontend:** add navigateToLink button for CopyTextField. close [#372](https://github.com/stonith404/pingvin-share/issues/372). ([#376](https://github.com/stonith404/pingvin-share/issues/376)) ([d775008](https://github.com/stonith404/pingvin-share/commit/d7750086b5b796cfc70d8dc0c7d0ab4bd1996ca0))
## [0.21.3](https://github.com/stonith404/pingvin-share/compare/v0.21.2...v0.21.3) (2024-01-02)
### Bug Fixes
* don't show validation error on upload modal if password or max views are empty ([fe09d0e](https://github.com/stonith404/pingvin-share/commit/fe09d0e25f6fbfc4e1c9302054d3387fe8b1f0ea))
## [0.21.2](https://github.com/stonith404/pingvin-share/compare/v0.21.1...v0.21.2) (2023-12-29)
### Bug Fixes
* missing logo images on fresh installation ([6fb31ab](https://github.com/stonith404/pingvin-share/commit/6fb31abd84b22cd464b6b45bf7ca6f83853e8720))
* missing translations on reset password page ([7a301b4](https://github.com/stonith404/pingvin-share/commit/7a301b455cdea4b1dbc04cc6223e094fee9aca7b))
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
### Features
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
### Features
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
### Bug Fixes
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
### Bug Fixes
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
### Bug Fixes
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
### Features
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
### Features
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
### Bug Fixes
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
### Bug Fixes
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
### Features
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
### Bug Fixes
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
### Bug Fixes
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
### Bug Fixes
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
### Features
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
### Bug Fixes
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
### Features
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
### Bug Fixes
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
### Bug Fixes
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
### Bug Fixes
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)

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

@@ -1,37 +1,41 @@
# Using node slim because prisma ORM needs libc for ARM builds
# Stage 1: on frontend dependency change
FROM node:19-slim AS frontend-dependencies
# Stage 1: Frontend dependencies
FROM node:20-alpine AS frontend-dependencies
WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Stage 2: on frontend change
FROM node:19-slim AS frontend-builder
# Stage 2: Build frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /opt/app
COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build
# Stage 3: on backend dependency change
FROM node:19-slim AS backend-dependencies
# 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
# Stage 4:on backend change
FROM node:19-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl
# Stage 4: Build backend
FROM node:20-alpine AS backend-builder
WORKDIR /opt/app
COPY ./backend .
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
RUN npx prisma generate
RUN npm run build && npm prune --production
RUN npm run build && npm prune --production
# Stage 5: Final image
FROM node:19-slim AS runner
FROM node:20-alpine AS runner
ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y curl openssl
# Install Caddy
RUN apk update --no-cache \
&& apk upgrade --no-cache \
&& apk add --no-cache curl caddy
COPY ./Caddyfile /etc/caddy/Caddyfile
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public
@@ -46,8 +50,11 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app
EXPOSE 3000
# Health check remains unchanged
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
# HOSTNAME=127.0.0.1 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 && HOSTNAME=127.0.0.1 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 & 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,9 +60,12 @@ 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 Caddy can be found in `./Caddyfile`.
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Integrations
@@ -77,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
@@ -108,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

1
backend/.prettierignore Normal file
View File

@@ -0,0 +1 @@
/src/constants.ts

10733
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.17.2",
"version": "0.26.0",
"scripts": {
"build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch",
@@ -13,68 +13,71 @@
"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",
"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",
"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

@@ -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)
@@ -134,7 +146,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,6 +56,16 @@ const configVariables: ConfigVariables = {
type: "number",
defaultValue: "9",
},
chunkSize: {
type: "number",
defaultValue: "10000000",
secret: false,
},
autoOpenShareModal: {
type: "boolean",
defaultValue: "false",
secret: false,
},
},
email: {
enableShareEmailRecipients: {
@@ -119,6 +139,97 @@ const configVariables: ConfigVariables = {
obscured: true,
},
},
oauth: {
"allowRegistration": {
type: "boolean",
defaultValue: "true",
},
"ignoreTotp": {
type: "boolean",
defaultValue: "true",
},
"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-clientId": {
type: "string",
defaultValue: "",
},
"oidc-clientSecret": {
type: "string",
defaultValue: "",
obscured: true,
},
}
};
type ConfigVariables = {
@@ -174,7 +285,7 @@ async function migrateConfigVariables() {
for (const existingConfigVariable of existingConfigVariables) {
const configVariable =
configVariables[existingConfigVariable.category]?.[
existingConfigVariable.name
existingConfigVariable.name
];
if (!configVariable) {
await prisma.config.delete({

View File

@@ -0,0 +1,19 @@
import { Controller, Get, Res } from "@nestjs/common";
import { Response } from "express";
import { PrismaService } from "./prisma/prisma.service";
@Controller("/")
export class AppController {
constructor(private prismaService: PrismaService) {}
@Get("health")
async health(@Res({ passthrough: true }) res: Response) {
try {
await this.prismaService.config.findMany();
return "OK";
} catch {
res.statusCode = 500;
return "ERROR";
}
}
}

View File

@@ -3,17 +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";
@Module({
imports: [
@@ -25,14 +28,21 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module";
ConfigModule,
JobsModule,
UserModule,
ThrottlerModule.forRoot({
ttl: 60,
limit: 100,
}),
ThrottlerModule.forRoot([
{
ttl: 60,
limit: 100,
},
]),
ScheduleModule.forRoot(),
ClamScanModule,
ReverseShareModule,
OAuthModule,
CacheModule.register({
isGlobal: true,
}),
],
controllers: [AppController],
providers: [
{
provide: APP_GUARD,

View File

@@ -33,43 +33,53 @@ export class AuthController {
constructor(
private authService: AuthService,
private authTotpService: AuthTotpService,
private config: ConfigService
private config: ConfigService,
) {}
@Post("signUp")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed");
const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
result.accessToken,
);
return result;
}
@Post("signIn")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(200)
async signIn(
@Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
result.accessToken,
);
}
@@ -77,32 +87,47 @@ export class AuthController {
}
@Post("signIn/totp")
@Throttle(10, 5 * 60)
@Throttle({
default: {
limit: 20,
ttl: 5 * 60,
},
})
@HttpCode(200)
async signInTotp(
@Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
result.accessToken,
);
return new TokenDTO().from(result);
}
@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);
@@ -113,15 +138,15 @@ export class AuthController {
async updatePassword(
@GetUser() user: User,
@Res({ passthrough: true }) response: Response,
@Body() dto: UpdatePasswordDTO
@Body() dto: UpdatePasswordDTO,
) {
const result = await this.authService.updatePassword(
user,
dto.password,
dto.oldPassword,
dto.password
);
response = this.addTokensToResponse(response, result.refreshToken);
this.authService.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result);
}
@@ -129,21 +154,21 @@ export class AuthController {
@HttpCode(200)
async refreshAccessToken(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token
request.cookies.refresh_token,
);
response = this.addTokensToResponse(response, undefined, accessToken);
this.authService.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken });
}
@Post("signOut")
async signOut(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 });
@@ -172,22 +197,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

@@ -8,6 +8,7 @@ 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";
@@ -21,13 +22,13 @@ export class AuthService {
private prisma: PrismaService,
private jwtService: JwtService,
private config: ConfigService,
private emailService: EmailService
private emailService: EmailService,
) {}
async signUp(dto: AuthRegisterDTO) {
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: {
@@ -39,17 +40,17 @@ export class AuthService {
});
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
user.id,
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
return { accessToken, refreshToken, user };
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
`A user with this ${duplicatedField} already exists`,
);
}
}
@@ -69,16 +70,23 @@ export class AuthService {
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
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 };
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
user.id,
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
@@ -91,7 +99,7 @@ export class AuthService {
include: { resetPasswordToken: true },
});
if (!user) throw new BadRequestException("User not found");
if (!user) return;
// Delete old reset password token
if (user.resetPasswordToken) {
@@ -129,9 +137,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);
@@ -158,7 +168,7 @@ export class AuthService {
{
expiresIn: "15min",
secret: this.config.get("internal.jwtSecret"),
}
},
);
}
@@ -189,13 +199,18 @@ export class AuthService {
return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
refreshTokenMetaData.id,
);
}
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 +225,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 * 15, // 15 minutes
});
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,10 +53,10 @@ 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,
refreshTokenId
token.user,
refreshTokenId,
);
return { accessToken, refreshToken };
@@ -92,13 +76,12 @@ 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"),
secret
"pingvin-share",
secret,
);
await this.prisma.user.update({

View File

@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
}
},
);

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

@@ -8,7 +8,10 @@ import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
constructor(
config: ConfigService,
private prisma: PrismaService,
) {
config.get("internal.jwtSecret");
super({
jwtFromRequest: JwtStrategy.extractJWT,

View File

@@ -19,7 +19,7 @@ export class ClamScanService {
constructor(
private fileService: FileService,
private prisma: PrismaService
private prisma: PrismaService,
) {}
private ClamScan: Promise<NodeClam | null> = new NodeClam()
@@ -81,7 +81,7 @@ export class ClamScanService {
});
this.logger.warn(
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
);
}
}

View File

@@ -28,7 +28,7 @@ export class ConfigController {
constructor(
private configService: ConfigService,
private logoService: LogoService,
private emailService: EmailService
private emailService: EmailService,
) {}
@Get()
@@ -41,7 +41,7 @@ export class ConfigController {
@UseGuards(JwtGuard, AdministratorGuard)
async getByCategory(@Param("category") category: string) {
return new AdminConfigDTO().fromList(
await this.configService.getByCategory(category)
await this.configService.getByCategory(category),
);
}
@@ -49,7 +49,7 @@ export class ConfigController {
@UseGuards(JwtGuard, AdministratorGuard)
async updateMany(@Body() data: UpdateConfigDTO[]) {
return new AdminConfigDTO().fromList(
await this.configService.updateMany(data)
await this.configService.updateMany(data),
);
}
@@ -66,9 +66,9 @@ export class ConfigController {
@UploadedFile(
new ParseFilePipe({
validators: [new FileTypeValidator({ fileType: "image/png" })],
})
}),
)
file: Express.Multer.File
file: Express.Multer.File,
) {
return await this.logoService.create(file.buffer);
}

View File

@@ -6,17 +6,24 @@ 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
) {}
private prisma: PrismaService,
) {
super();
}
get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter(
(variable) => `${variable.category}.${variable.name}` == key
(variable) => `${variable.category}.${variable.name}` == key,
)[0];
if (!configVariable) throw new Error(`Config variable ${key} not found`);
@@ -89,7 +96,7 @@ export class ConfigService {
configVariable.type != "text"
) {
throw new BadRequestException(
`Config variable must be of type ${configVariable.type}`
`Config variable must be of type ${configVariable.type}`,
);
}
@@ -105,6 +112,8 @@ export class ConfigService {
this.configVariables = await this.prisma.config.findMany();
this.emit("update", key, value);
return updatedVariable;
}
}

View File

@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
fromList(partial: Partial<AdminConfigDTO>[]) {
return partial.map((part) =>
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

@@ -12,7 +12,7 @@ export class ConfigDTO {
fromList(partial: Partial<ConfigDTO>[]) {
return partial.map((part) =>
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

@@ -7,7 +7,8 @@ const IMAGES_PATH = "../frontend/public/img";
@Injectable()
export class LogoService {
async create(file: Buffer) {
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
const resized = await sharp(file).resize(900).toBuffer();
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
this.createFavicon(file);
this.createPWAIcons(file);
}
@@ -25,7 +26,7 @@ export class LogoService {
fs.promises.writeFile(
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
resized,
"binary"
"binary",
);
}
}

View File

@@ -32,7 +32,7 @@ export class EmailService {
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
"smtp.email",
)}>`,
to: email,
subject,
@@ -49,7 +49,7 @@ export class EmailService {
shareId: string,
creator?: User,
description?: string,
expiration?: Date
expiration?: Date,
) {
if (!this.config.get("email.enableShareEmailRecipients"))
throw new InternalServerErrorException("Email service disabled");
@@ -69,8 +69,8 @@ export class EmailService {
"{expires}",
moment(expiration).unix() != 0
? moment(expiration).fromNow()
: "in: never"
)
: "in: never",
),
);
}
@@ -83,13 +83,13 @@ export class EmailService {
this.config
.get("email.reverseShareMessage")
.replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl)
.replaceAll("{shareUrl}", shareUrl),
);
}
async sendResetPasswordEmail(recipientEmail: string, token: string) {
const resetPasswordUrl = `${this.config.get(
"general.appUrl"
"general.appUrl",
)}/auth/resetPassword/${token}`;
await this.sendMail(
@@ -98,7 +98,7 @@ export class EmailService {
this.config
.get("email.resetPasswordMessage")
.replaceAll("\\n", "\n")
.replaceAll("{url}", resetPasswordUrl)
.replaceAll("{url}", resetPasswordUrl),
);
}
@@ -111,7 +111,7 @@ export class EmailService {
this.config
.get("email.inviteMessage")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password)
.replaceAll("{password}", password),
);
}
@@ -119,7 +119,7 @@ export class EmailService {
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
"smtp.email",
)}>`,
to: recipientEmail,
subject: "Test email",

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
@@ -25,21 +26,24 @@ 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
@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
shareId,
);
}
@@ -47,7 +51,7 @@ export class FileController {
@UseGuards(FileSecurityGuard)
async getZip(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string
@Param("shareId") shareId: string,
) {
const zip = this.fileService.getZip(shareId);
res.set({
@@ -64,13 +68,14 @@ export class FileController {
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string,
@Query("download") download = "true"
@Query("download") download = "true",
) {
const file = await this.fileService.get(shareId, fileId);
const headers = {
"Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size,
"Content-Security-Policy": "script-src 'none'",
};
if (download === "true") {
@@ -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

@@ -18,14 +18,14 @@ export class FileService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private config: ConfigService
private config: ConfigService,
) {}
async create(
data: string,
chunk: { index: number; total: number },
file: { id?: string; name: string },
shareId: string
shareId: string,
) {
if (!file.id) file.id = crypto.randomUUID();
@@ -40,14 +40,14 @@ export class FileService {
let diskFileSize: number;
try {
diskFileSize = fs.statSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
).size;
} catch {
diskFileSize = 0;
}
// If the sent chunk index and the expected chunk index doesn't match throw an error
const chunkSize = 10 * 1024 * 1024; // 10MB
const chunkSize = this.config.get("share.chunkSize");
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
if (expectedChunkIndex != chunk.index)
@@ -62,7 +62,7 @@ export class FileService {
// Check if share size limit is exceeded
const fileSizeSum = share.files.reduce(
(n, { size }) => n + parseInt(size),
0
0,
);
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
@@ -74,23 +74,23 @@ export class FileService {
) {
throw new HttpException(
"Max share size exceeded",
HttpStatus.PAYLOAD_TOO_LARGE
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
fs.appendFileSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer
buffer,
);
const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) {
fs.renameSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
);
const fileSize = fs.statSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
).size;
await this.prisma.file.create({
data: {
@@ -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

@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
export class FileSecurityGuard extends ShareSecurityGuard {
constructor(
private _shareService: ShareService,
private _prisma: PrismaService
private _prisma: PrismaService,
) {
super(_shareService, _prisma);
}
@@ -24,7 +24,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
"shareId",
)
? request.params.shareId
: request.params.id;
@@ -52,7 +52,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
"share_max_views_exceeded",
);
}

View File

@@ -14,7 +14,7 @@ export class JobsService {
constructor(
private prisma: PrismaService,
private reverseShareService: ReverseShareService,
private fileService: FileService
private fileService: FileService,
) {}
@Cron("0 * * * *")
@@ -56,11 +56,33 @@ export class JobsService {
if (expiredReverseShares.length > 0) {
this.logger.log(
`Deleted ${expiredReverseShares.length} expired reverse shares`
`Deleted ${expiredReverseShares.length} expired reverse shares`,
);
}
}
@Cron("0 */6 * * *")
async deleteUnfinishedShares() {
const unfinishedShares = await this.prisma.share.findMany({
where: {
createdAt: { lt: moment().subtract(1, "day").toDate() },
uploadLocked: false,
},
});
for (const unfinishedShare of unfinishedShares) {
await this.prisma.share.delete({
where: { id: unfinishedShare.id },
});
await this.fileService.deleteAllFiles(unfinishedShare.id);
}
if (unfinishedShares.length > 0) {
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
}
}
@Cron("0 0 * * *")
deleteTemporaryFiles() {
let filesDeleted = 0;
@@ -77,7 +99,7 @@ export class JobsService {
for (const file of temporaryFiles) {
const stats = fs.statSync(
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
);
const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day")
@@ -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,6 @@
export interface OAuthSignInDto {
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
providerId: string;
providerUsername: string;
email: string;
}

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);
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,171 @@
import { Inject, Injectable } 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[],
) {}
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) {
const oauthUser = await this.prisma.oAuthUser.findFirst({
where: {
provider: user.provider,
providerUserId: user.providerId,
},
include: {
user: true,
},
});
if (oauthUser) {
return this.auth.generateToken(oauthUser.user, true);
}
return this.signUp(user);
}
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) {
// 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,
},
});
return this.auth.generateToken(existingUser, true);
}
const result = await this.auth.signUp({
email: user.email,
username: await this.getAvailableUsername(user.providerUsername),
password: null,
});
await this.prisma.oAuthUser.create({
data: {
provider: user.provider,
providerUserId: user.providerId.toString(),
providerUsername: user.providerUsername,
userId: result.user.id,
},
});
return result;
}
}

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("discord_guild_permission_denied");
}
} catch {
throw new ErrorPageException("discord_guild_permission_denied");
}
}
}
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,236 @@
import { Logger } from "@nestjs/common";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
import { Cache } from "cache-manager";
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,
): 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;
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,
};
}
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,39 @@
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;
return super.getUserInfo(token, query, claim);
}
}

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()
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
return partial.map((part) =>
plainToClass(ReverseShareTokenWithShares, part, {
excludeExtraneousValues: true,
})
}),
);
}
}

View File

@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
export class ReverseShareController {
constructor(
private reverseShareService: ReverseShareService,
private config: ConfigService
private config: ConfigService,
) {}
@Post()
@@ -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);
@@ -44,7 +49,7 @@ export class ReverseShareController {
if (!isValid) throw new NotFoundException("Reverse share token not found");
return new ReverseShareDTO().from(
await this.reverseShareService.getByToken(reverseShareToken)
await this.reverseShareService.getByToken(reverseShareToken),
);
}
@@ -52,7 +57,7 @@ export class ReverseShareController {
@UseGuards(JwtGuard)
async getAllByUser(@GetUser() user: User) {
return new ReverseShareTokenWithShares().fromList(
await this.reverseShareService.getAllByUser(user.id)
await this.reverseShareService.getAllByUser(user.id),
);
}

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()
@@ -10,7 +11,7 @@ export class ReverseShareService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private fileService: FileService
private fileService: FileService,
) {}
async create(data: CreateReverseShareDTO, creatorId: string) {
@@ -19,16 +20,27 @@ export class ReverseShareService {
.add(
data.shareExpiration.split("-")[0],
data.shareExpiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
"-",
)[1] as moment.unitOfTime.DurationConstructor,
)
.toDate();
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
if (
this.config.get("share.maxExpiration") !== 0 &&
parsedExpiration >
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
) {
throw new BadRequestException(
"Expiration date exceeds maximum expiration date",
);
}
const globalMaxShareSize = this.config.get("share.maxSize");
if (globalMaxShareSize < data.maxShareSize)
throw new BadRequestException(
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
`Max share size can't be greater than ${globalMaxShareSize} bytes.`,
);
const reverseShare = await this.prisma.reverseShare.create({

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

@@ -27,7 +27,7 @@ export class MyShareDTO extends OmitType(ShareDTO, [
fromList(partial: Partial<MyShareDTO>[]) {
return partial.map((part) =>
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

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

View File

@@ -7,7 +7,7 @@ import { ReverseShareService } from "src/reverseShare/reverseShare.service";
export class CreateShareGuard extends JwtGuard {
constructor(
configService: ConfigService,
private reverseShareService: ReverseShareService
private reverseShareService: ReverseShareService,
) {
super(configService);
}
@@ -20,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,22 +1,28 @@
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();
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
"shareId",
)
? request.params.shareId
: request.params.id;
@@ -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

@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
export class ShareSecurityGuard implements CanActivate {
constructor(
private shareService: ShareService,
private prisma: PrismaService
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext) {
@@ -22,7 +22,7 @@ export class ShareSecurityGuard implements CanActivate {
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
"shareId",
)
? request.params.shareId
: request.params.id;
@@ -44,13 +44,13 @@ export class ShareSecurityGuard implements CanActivate {
if (share.security?.password && !shareToken)
throw new ForbiddenException(
"This share is password protected",
"share_password_required"
"share_password_required",
);
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
throw new ForbiddenException(
"Share token required",
"share_token_required"
"share_token_required",
);
return true;

View File

@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
"shareId",
)
? request.params.shareId
: request.params.id;

View File

@@ -14,7 +14,9 @@ import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { AdminShareDTO } from "./dto/adminShare.dto";
import { CreateShareDTO } from "./dto/createShare.dto";
import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto";
@@ -29,11 +31,17 @@ import { ShareService } from "./share.service";
export class ShareController {
constructor(private shareService: ShareService) {}
@Get("all")
@UseGuards(JwtGuard, AdministratorGuard)
async getAllShares() {
return new AdminShareDTO().fromList(await this.shareService.getShares());
}
@Get()
@UseGuards(JwtGuard)
async getMyShares(@GetUser() user: User) {
return new MyShareDTO().fromList(
await this.shareService.getSharesByUser(user.id)
await this.shareService.getSharesByUser(user.id),
);
}
@@ -43,6 +51,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) {
@@ -54,44 +68,61 @@ export class ShareController {
async create(
@Body() body: CreateShareDTO,
@Req() request: Request,
@GetUser() user: User
@GetUser() user: User,
) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.create(body, user, reverse_share_token)
await this.shareService.create(body, user, reverse_share_token),
);
}
@Delete(":id")
@UseGuards(JwtGuard, ShareOwnerGuard)
async remove(@Param("id") id: string) {
await this.shareService.remove(id);
}
@Post(":id/complete")
@HttpCode(202)
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token)
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,
@Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto
@Body() body: SharePasswordDto,
) {
const token = await this.shareService.getShareToken(id, body.password);
response.cookie(`share_${id}_token`, token, {

View File

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

View File

@@ -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";
@@ -28,7 +29,7 @@ export class ShareService {
private config: ConfigService,
private jwtService: JwtService,
private reverseShareService: ReverseShareService,
private clamScanService: ClamScanService
private clamScanService: ClamScanService,
) {}
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
@@ -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}`, {
@@ -134,13 +139,13 @@ export class ShareService {
if (share.files.length == 0)
throw new BadRequestException(
"You need at least on file in your share to complete it."
"You need at least on file in your share to complete it.",
);
// Asynchronously create a zip of all files
if (share.files.length > 1)
this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
);
// Send email for each recipient
@@ -150,7 +155,7 @@ export class ShareService {
share.id,
share.creator,
share.description,
share.expiration
share.expiration,
);
}
@@ -161,7 +166,7 @@ export class ShareService {
) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id
share.id,
);
}
@@ -181,6 +186,29 @@ export class ShareService {
});
}
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) {
const shares = await this.prisma.share.findMany({
where: {
@@ -201,6 +229,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),
};
});
@@ -238,13 +267,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);
@@ -285,7 +315,7 @@ export class ShareService {
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
"share_max_views_exceeded",
);
}
@@ -305,7 +335,7 @@ export class ShareService {
{
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("internal.jwtSecret"),
}
},
);
}

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;
@@ -31,7 +34,7 @@ export class UserDTO {
fromList(partial: Partial<UserDTO>[]) {
return partial.map((part) =>
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

@@ -27,15 +27,18 @@ 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")
@UseGuards(JwtGuard)
async updateCurrentUser(
@GetUser() user: User,
@Body() data: UpdateOwnUserDTO
@Body() data: UpdateOwnUserDTO,
) {
return new UserDTO().from(await this.userService.update(user.id, data));
}
@@ -44,7 +47,7 @@ export class UserController {
@UseGuards(JwtGuard)
async deleteCurrentUser(
@GetUser() user: User,
@Res({ passthrough: true }) response: Response
@Res({ passthrough: true }) response: Response,
) {
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {

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";
@@ -11,7 +12,8 @@ import { UpdateUserDto } from "./dto/updateUser.dto";
export class UserSevice {
constructor(
private prisma: PrismaService,
private emailService: EmailService
private emailService: EmailService,
private fileService: FileService,
) {}
async list() {
@@ -46,7 +48,7 @@ export class UserSevice {
if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
`A user with this ${duplicatedField} already exists`,
);
}
}
@@ -66,7 +68,7 @@ export class UserSevice {
if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
`A user with this ${duplicatedField} already exists`,
);
}
}
@@ -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

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

View File

@@ -11,6 +11,7 @@
"react-hooks/exhaustive-deps": ["off"],
"import/no-anonymous-default-export": ["off"],
"no-unused-vars": ["warn"],
"react/no-unescaped-entities": ["off"]
"react/no-unescaped-entities": ["off"],
"@next/next/no-img-element": ["off"]
}
}

1
frontend/.prettierignore Normal file
View File

@@ -0,0 +1 @@
/src/i18n/translations/*

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,55 @@
{
"name": "pingvin-share-frontend",
"version": "0.17.2",
"version": "0.26.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/server.js",
"start": "next start",
"lint": "next lint",
"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",
"moment": "^2.30.1",
"next": "^14.2.3",
"next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5",
"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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,6 +1,4 @@
import Image from "next/image";
const Logo = ({ height, width }: { height: number; width: number }) => {
return <Image src="/img/logo.png" alt="logo" height={height} width={width} />;
return <img src="/img/logo.png" alt="logo" height={height} width={width} />;
};
export default Logo;

View File

@@ -7,7 +7,7 @@ import { LOCALES } from "../../i18n/locales";
const LanguagePicker = () => {
const t = useTranslate();
const [selectedLanguage, setSelectedLanguage] = useState(
getCookie("language")?.toString()
getCookie("language")?.toString(),
);
const languages = Object.values(LOCALES).map((locale) => ({
@@ -23,7 +23,7 @@ const LanguagePicker = () => {
setCookie("language", value, {
sameSite: "lax",
expires: new Date(
new Date().setFullYear(new Date().getFullYear() + 1)
new Date().setFullYear(new Date().getFullYear() + 1),
),
});
location.reload();

View File

@@ -14,7 +14,7 @@ import userPreferences from "../../utils/userPreferences.util";
const ThemeSwitcher = () => {
const [colorScheme, setColorScheme] = useState(
userPreferences.get("colorScheme")
userPreferences.get("colorScheme"),
);
const { toggleColorScheme } = useMantineColorScheme();
const systemColorScheme = useColorScheme();
@@ -26,7 +26,7 @@ const ThemeSwitcher = () => {
userPreferences.set("colorScheme", value);
setColorScheme(value);
toggleColorScheme(
value == "system" ? systemColorScheme : (value as ColorScheme)
value == "system" ? systemColorScheme : (value as ColorScheme),
);
}}
data={[

View File

@@ -26,7 +26,7 @@ const showEnableTotpModal = (
qrCode: string;
secret: string;
password: string;
}
},
) => {
const t = translateOutsideContext();
return modals.openModal({
@@ -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

@@ -5,7 +5,7 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
const showReverseShareLinkModal = (
modals: ModalsContextProps,
reverseShareToken: string,
appUrl: string
appUrl: string,
) => {
const t = translateOutsideContext();
const link = `${appUrl}/upload/${reverseShareToken}`;

View File

@@ -12,18 +12,14 @@ const showShareInformationsModal = (
modals: ModalsContextProps,
share: MyShare,
appUrl: string,
maxShareSize: number
maxShareSize: number,
) => {
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

@@ -5,7 +5,7 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
const showShareLinkModal = (
modals: ModalsContextProps,
shareId: string,
appUrl: string
appUrl: string,
) => {
const t = translateOutsideContext();
const link = `${appUrl}/s/${shareId}`;

View File

@@ -21,7 +21,7 @@ const AdminConfigInput = ({
stringValue: configVariable.value ?? configVariable.defaultValue,
textValue: configVariable.value ?? configVariable.defaultValue,
numberValue: parseInt(
configVariable.value ?? configVariable.defaultValue
configVariable.value ?? configVariable.defaultValue,
),
booleanValue:
(configVariable.value ?? configVariable.defaultValue) == "true",

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

@@ -33,7 +33,7 @@ const TestEmailButton = ({
<Textarea minRows={4} readOnly value={e.response.data.message} />
</Stack>
),
})
}),
);
};

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

@@ -17,7 +17,7 @@ import toast from "../../../utils/toast.util";
const showCreateUserModal = (
modals: ModalsContextProps,
smtpEnabled: boolean,
getUsers: () => void
getUsers: () => void,
) => {
return modals.openModal({
title: "Create user",
@@ -55,7 +55,7 @@ const Body = ({
.string()
.min(8, t("common.error.too-short", { length: 8 }))
.optional(),
})
}),
),
});
@@ -87,7 +87,7 @@ const Body = ({
labelPosition="left"
label={t("admin.users.modal.create.manual-password")}
description={t(
"admin.users.modal.create.manual-password.description"
"admin.users.modal.create.manual-password.description",
)}
{...form.getInputProps("setPasswordManually", {
type: "checkbox",

View File

@@ -21,7 +21,7 @@ import toast from "../../../utils/toast.util";
const showUpdateUserModal = (
modals: ModalsContextProps,
user: User,
getUsers: () => void
getUsers: () => void,
) => {
const t = translateOutsideContext();
return modals.openModal({
@@ -53,7 +53,7 @@ const Body = ({
username: yup
.string()
.min(3, t("common.error.too-short", { length: 3 })),
})
}),
),
});
@@ -66,7 +66,7 @@ const Body = ({
password: yup
.string()
.min(8, t("common.error.too-short", { length: 8 })),
})
}),
),
});
@@ -115,8 +115,8 @@ const Body = ({
})
.then(() =>
toast.success(
t("admin.users.edit.update.notify.password.success")
)
t("admin.users.edit.update.notify.password.success"),
),
)
.catch(toast.axiosError);
})}

Some files were not shown because too many files have changed in this diff Show More