Compare commits

...

241 Commits

Author SHA1 Message Date
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
Elias Schneider
72a52eb33f release: 0.17.2 2023-07-31 15:37:12 +02:00
Elias Schneider
c9a2a469c6 fix: ECONNREFUSED with Docker ipv6 enabled 2023-07-31 15:37:04 +02:00
Elias Schneider
b534129194 chore(translations): remove Thai 2023-07-31 08:56:22 +02:00
Elias Schneider
0beebfd779 chore(translation): add Russian 2023-07-31 08:55:17 +02:00
Elias Schneider
2ed5ecc1ea release: 0.17.1 2023-07-30 22:34:33 +02:00
Elias Schneider
9bb05158c5 chore: update deps 2023-07-30 22:34:10 +02:00
Elias Schneider
36230371fd chore: update translations via Crowdin (#216)
* New translations en-US.ts (Finnish)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Finnish)
2023-07-30 22:19:15 +02:00
Elias Schneider
5fd79a35cb chore: add translation file for Finnish 2023-07-30 21:00:20 +02:00
Elias Schneider
cecaa90e15 chore: update translations via Crowdin (#215)
* New translations en-US.ts (Portuguese)

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

* New translations en-US.ts (Portuguese, Brazilian)
2023-07-30 20:58:23 +02:00
Elias Schneider
2584bb0d48 fix: rename pt-PT.ts to pt-BR.ts 2023-07-25 17:07:38 +02:00
Elias Schneider
82008aa261 chore: update translations via Crowdin (#207)
* New translations en-US.ts (French)

* New translations en-US.ts (French)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Danish)

* New translations en-US.ts (German)

* New translations en-US.ts (Portuguese)

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

* New translations en-US.ts (Thai)

* New translations en-US.ts (French)

* New translations en-US.ts (French)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (German)

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

* New translations en-US.ts (Portuguese, Brazilian)
2023-07-25 17:05:12 +02:00
Elias Schneider
a07a78a138 chore: update translations via Crowdin (#206)
* 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 (Portuguese)

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

* New translations en-US.ts (Thai)
2023-07-23 14:02:53 +02:00
Elias Schneider
2618bbb897 release: 0.17.0 2023-07-23 13:42:54 +02:00
Elias Schneider
6667c7a8d7 Merge branch 'main' of https://github.com/stonith404/pingvin-share 2023-07-23 13:42:13 +02:00
Elias Schneider
7f0c31c2e0 feat: add note to language picker 2023-07-23 13:42:10 +02:00
Elias Schneider
3165dcf9e6 chore: update translations via Crowdin (#205)
* New translations en-US.ts (German)

* New translations en-US.ts (German)

* 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 (Portuguese)

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

* New translations en-US.ts (Thai)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Portuguese)
2023-07-23 12:36:17 +02:00
Elias Schneider
f4c88aeb08 fix: wrong layout if button text is too long in modals 2023-07-22 16:23:04 +02:00
Elias Schneider
231a2e95b9 feat: add share url alias /s 2023-07-22 16:09:10 +02:00
Elias Schneider
7827b687fa feat: ability to define zip compression level 2023-07-22 15:44:45 +02:00
Elias Schneider
389dc87cac feat: update default value of maxSize from 1073741824 to 1000000000 2023-07-22 15:33:45 +02:00
Elias Schneider
5816b39fc6 fix: confusion between GB and GiB 2023-07-22 15:29:53 +02:00
Elias Schneider
890588f5da refactor: use locale instead of two letter code 2023-07-22 13:08:42 +02:00
Elias Schneider
e6a2014875 chore: update translations via Crowdin (#204)
* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

* New translations en.ts (Thai)
2023-07-22 12:49:07 +02:00
Elias Schneider
396363488c chore: minor translation fixes 2023-07-22 12:43:12 +02:00
Elias Schneider
424331ed1a chore: update translations via Crowdin (#203)
* New translations en.ts (German)

* New translations en.ts (French)

* New translations en.ts (French)

* New translations en.ts (German)

* New translations zh-CN.ts (Chinese Simplified) (#202)

* finish Simplified Chinese trans in zh-CN.ts

* fix type error at line:270

---------

Co-authored-by: YunChao <yunchaozk@outlook.com>
2023-07-22 12:36:51 +02:00
Elias Schneider
d198a132db chore: update translations via Crowdin (#200)
* New translations en.ts (German)

* New translations en.ts (French)

* New translations en.ts (French)

* New translations en.ts (German)
2023-07-22 12:34:26 +02:00
Elias Schneider
a041a6969d chore: update translations via Crowdin (#197)
* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

* New translations en.ts (Thai)

* New translations en.ts (French)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

* New translations en.ts (Thai)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Spanish)

* New translations en.ts (German)

* New translations en.ts (German)
2023-07-20 23:45:09 +02:00
Elias Schneider
be57bd3354 chore: update crowdin PR title 2023-07-20 23:44:01 +02:00
Elias Schneider
70b425b380 fix: mistakes in English translations 2023-07-20 19:42:55 +02:00
Elias Schneider
8259eb286c docs: update translation docs 2023-07-20 15:57:36 +02:00
Elias Schneider
7071d8bd87 chore: improve language request template 2023-07-20 15:51:03 +02:00
Elias Schneider
b2ed7b74c0 chore: add language request issue template 2023-07-20 15:49:01 +02:00
Elias Schneider
b9f6e3bd08 feat: localization (#196)
* Started adding locale translations :)

* Added some more translations

* Working on translating even more pages

* More translations

* Added test default locale retrieval

* replace `intl.formatMessage` with custom `t` hook

* add more translations

* improve title syntax

* add more translations

* translate admin config page

* translated error messages

* add language selecter

* minor fixes

* improve language handling

* add upcoming languages

* add `crowdin.yml`

* run formatter

---------

Co-authored-by: Steve Tautonico <stautonico@gmail.com>
2023-07-20 15:32:07 +02:00
Elias Schneider
7c5ec8d0ea release: 0.16.1 2023-07-10 14:13:58 +02:00
Pierre Bidet
0276294f52 feat: Adding reverse shares' shares a clickable link (#190)
* Add clickable link to reverse share's shares

* Ran format

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-07-10 14:01:55 +02:00
Pierre Bidet
7574eb3191 feat: Adding reverse share ability to copy the link (#191)
* Add clickable link to reverse share's shares

* Ran format

* Adding copy icon to the reverse share list

* Remove console.log

* Ran format

* Ran format in backend

* fix: copy to clipboard spelling

* Open the share in another window

* feat: Adding reverse shares' shares a clickable link (#178)

* Add clickable link to reverse share's shares

* Ran format

* fix: set link default value to random (#181)

* fix: set link default value to random

* fix: add auto EOL and add conventional-changelog package

* Apply suggestions from code review

---------

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

* feat: Adding reverse share ability to copy the link (#179)

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-07-10 13:58:29 +02:00
Pierre Bidet
a1ea7c0265 fix: set link default value to random (#192)
* fix: set link default value to random

* fix: add auto EOL and add conventional-changelog package

* feat: Adding reverse shares' shares a clickable link (#178)

* Add clickable link to reverse share's shares

* Ran format

* Apply suggestions from code review

* fix: set link default value to random (#181)

* fix: set link default value to random

* fix: add auto EOL and add conventional-changelog package

* Apply suggestions from code review

---------

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

* feat: Adding reverse share ability to copy the link (#179)

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-07-10 13:58:17 +02:00
Elias Schneider
adf0f8d57e release: 0.16.0 2023-07-09 17:15:26 +02:00
Elias Schneider
447c86f1c9 chore: remove backend Dockerfile 2023-06-28 15:45:54 +02:00
pierrbt
1466240461 feat: Adding more informations on My Shares page (table and modal) (#174)
* Adding an information button to the shares and corrected MyShare interface

* Adding other informations and disk usage

* Adding description, disk usage

* Add case if the expiration is never

* Adding file size and better UI

* UI changes to Information Modal

* Adding description to the My Shares page

* Ran format

* Remove string type

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

* Remove string type check

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

* Remove string type conversion

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

* Variable name changes

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

* Remove color

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

* Requested changes made

* Ran format

* Adding MediaQuery

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-06-26 08:22:15 +02:00
pierrbt
348852cfa4 feat: Adding the possibility of copying the link by clicking text and icons (#171) 2023-06-23 20:07:49 +02:00
Elias Schneider
932496a121 release: 0.15.0 2023-05-09 09:18:31 +02:00
Elias Schneider
0c7b2a8e70 docs: add environment variables to the README 2023-05-09 09:18:02 +02:00
Elias Schneider
1df5c7123e feat: allow to configure clamav with environment variables 2023-05-09 08:45:56 +02:00
Elias Schneider
2dc0fc9332 refactor: improve logging 2023-05-09 08:45:30 +02:00
Elias Schneider
98c0de78e8 feat: add env variables for port, database url and data dir 2023-05-05 11:37:02 +02:00
Elias Schneider
5132d177b8 feat: add healthcheck endpoint 2023-04-27 22:31:06 +02:00
Elias Schneider
e5071cba12 feat: configure ports, db url and api url with env variables 2023-04-25 23:39:57 +02:00
Elias Schneider
b33c1d7f4b release: 0.14.1 2023-04-07 23:13:54 +02:00
Elias Schneider
39a74510c1 fix: boolean config variables can't be set to false 2023-04-07 23:13:44 +02:00
Elias Schneider
b7db9b9b40 refactor: simplify create share function 2023-04-04 22:47:32 +02:00
Elias Schneider
2ca0092b71 docs: fix translation path 2023-04-02 18:55:41 +02:00
Elias Schneider
b4bf43910e docs: move translated docs in docs folder 2023-04-02 18:53:54 +02:00
AC6
90aa919694 docs: add Simplified Chinese version of README and CONTRIBUTING (#139)
* add simplified Chinese translation for README.md

* add simplified Chinese translation for CONTRIBUTING.md
2023-04-02 18:49:03 +02:00
Elias Schneider
f2e4019190 release: 0.14.0 2023-04-01 20:19:27 +02:00
Rooyca
ffd4e43f11 docs: add Spanish version of README and CONTRIBUTING (#138)
* doc: add Spanish version of README and CONTRIBUTING

* docs: change h3 tag from language switch to normal size
2023-04-01 20:15:47 +02:00
Elias Schneider
0e5c673270 fix: bool config variable can't be changed 2023-03-24 21:37:39 +01:00
iUnstable0
beece56327 feat(share, config): more variables, placeholder and reset default (#132)
* More email share vars + unfinished placeolders config

{desc} {expires} vars
(unfinished) config placeholder vals

* done

* migrate

* edit seed

* removed comments

* refactor: replace dependecy `luxon` with `moment`

* update shareRecipientsMessage message

* chore: remove `luxon`

* fix: grammatically incorrect `shareRecipientsMessage` message

* changed to defaultValue and value instead

* fix: don't expose defaultValue to non admin user

* fix: update default value if default value changes

* refactor: set config value to null instead of a empty string

* refactor: merge two migrations into one

* fix value check empty

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-03-23 08:31:21 +01:00
iUnstable0
a0d1d98e24 docs: improve stand-alone upgrade guide (#128)
* Update README.md

* docs: improve stand-alone upgrade guide

* Update README.md
2023-03-16 09:21:53 +01:00
Elias Schneider
ca73ccf629 release: 0.13.1 2023-03-14 20:26:04 +01:00
Elias Schneider
9f2097e788 fix: empty file can't be uploaded in chrome 2023-03-14 20:24:21 +01:00
Elias Schneider
2158df4228 release: 0.13.0 2023-03-14 16:09:20 +01:00
Elias Schneider
37e765ddc7 fix: show line breaks in txt preview 2023-03-14 16:08:57 +01:00
Elias Schneider
a91c531642 docs: update main screenshot 2023-03-14 15:47:42 +01:00
Elias Schneider
5a7f7ca2f6 chore: dump node js version 2023-03-14 15:36:35 +01:00
Elias Schneider
813ee4de2c refactor: rename deprecated Prisma imports 2023-03-14 15:11:24 +01:00
Elias Schneider
b25c30d1ed feat: sort shared files 2023-03-14 14:50:18 +01:00
Elias Schneider
c807d208d8 feat: add preview modal 2023-03-14 12:09:21 +01:00
Elias Schneider
f82099f36e fix: upload file if it is 0 bytes 2023-03-13 08:57:56 +01:00
Elias Schneider
6345e21db9 refactor: globalize modal title style 2023-03-13 08:50:54 +01:00
Elias Schneider
f55aa80516 fix: replace "pingvin share" with dynamic app name 2023-03-12 20:13:55 +01:00
Elias Schneider
0ce8b528e1 refactor: improve error handling for failed emails 2023-03-12 19:29:39 +01:00
Elias Schneider
8ff417a013 fix: set password manually input not shown 2023-03-12 19:28:50 +01:00
Elias Schneider
cb1a0d4090 release: 0.12.1 2023-03-11 12:40:27 +01:00
Elias Schneider
753dbe83b7 fix: 48px icon does not update 2023-03-11 12:33:22 +01:00
Elias Schneider
0c2a62b0ca release: 0.12.0 2023-03-10 09:40:19 +01:00
Elias Schneider
452c635933 chore: dump packages 2023-03-10 09:40:09 +01:00
Elias Schneider
0455ba1bc1 chore: upgrade mantine to v6 2023-03-10 09:01:33 +01:00
Elias Schneider
3ad6b03b6b fix: home page shown even if disabled 2023-03-10 08:40:32 +01:00
Elias Schneider
91c3525b15 chore: add sharp for image optimizations 2023-03-08 17:47:36 +01:00
Elias Schneider
8403d7e14d feat: ability to change logo in frontend 2023-03-08 14:47:41 +01:00
Elias Schneider
8f71fd3435 fix: crypto is not defined 2023-03-08 13:10:10 +01:00
Elias Schneider
155c743197 release: 0.11.1 2023-03-05 10:50:32 +01:00
Elias Schneider
8b77e81d4c fix: old config variable prevents to create a share 2023-03-05 10:48:01 +01:00
Elias Schneider
22d81b2220 release: 0.11.0 2023-03-04 23:41:11 +01:00
Elias Schneider
0317f3a508 fix: frontend error when user deleted 2023-03-04 23:40:02 +01:00
Elias Schneider
fddad3ef70 feat: custom branding (#112)
* add first concept

* remove setup status

* split config page in multiple components

* add custom branding docs

* add test email button

* fix invalid email from header

* add migration

* mount images to host

* update docs

* remove unused endpoint

* run formatter
2023-03-04 23:29:00 +01:00
Elias Schneider
f9840505b8 feat: invite new user with email 2023-02-21 08:51:04 +01:00
Elias Schneider
759c55f625 docs: fix remove app before upgrading 2023-02-13 10:09:53 +01:00
Elias Schneider
edb511252f release: 0.10.2 2023-02-13 09:39:43 +01:00
Elias Schneider
c3af0fe097 fix: pdf preview tries to render on server 2023-02-13 09:39:27 +01:00
Elias Schneider
6419da07fb release: 0.10.1 2023-02-12 20:00:55 +01:00
Elias Schneider
7cd9dff637 fix: setup wizard doesn't redirect after completion 2023-02-12 20:00:35 +01:00
Elias Schneider
2a826f7941 docs: stand-alone installation start backend first 2023-02-12 19:04:12 +01:00
Elias Schneider
8720232755 chore: add question issue template 2023-02-12 14:40:10 +01:00
Elias Schneider
dc8cf3d5ca fix: non administrator user redirection error while setup isn't finished 2023-02-11 15:57:21 +01:00
Elias Schneider
979b882150 docs: add stand-alone installation guide 2023-02-10 14:59:19 +01:00
Elias Schneider
c55019f71b docs: improve contributing guideline 2023-02-10 14:22:32 +01:00
Elias Schneider
4c6ef52a17 release: 0.10.0 2023-02-10 11:47:29 +01:00
Elias Schneider
b9662701c4 fix: share creation without reverseShareToken 2023-02-10 11:47:17 +01:00
Elias Schneider
e3f88d0826 refactor(jobs): clear expired tokens and reverse shares 2023-02-10 11:29:51 +01:00
Elias Schneider
86a7379519 fix: delete all shares of reverse share 2023-02-10 11:15:23 +01:00
Elias Schneider
ccdf8ea3ae feat: allow multiple shares with one reverse share link 2023-02-10 11:10:07 +01:00
Elias Schneider
edc10b72b7 fix: share fails if a share was created with a reverse share link recently 2023-02-10 10:58:49 +01:00
Elias Schneider
5d1a7f0310 feat!: reset password with email 2023-02-09 18:17:53 +01:00
Elias Schneider
8ab359b71d docs(backend): add swagger documentation 2023-02-07 11:23:43 +01:00
Elias Schneider
38de022215 feat(frontend): server side rendering to improve performance 2023-02-07 10:21:25 +01:00
Elias Schneider
82f204e8a9 fix: invalid redirection after jwt expiry 2023-02-06 11:15:46 +01:00
Elias Schneider
4e840ecd29 refactor: handle authentication state in middleware 2023-02-04 18:12:49 +01:00
Elias Schneider
064ef38d78 fix: setup status doesn't change 2023-02-03 11:01:10 +01:00
Elias Schneider
b14e931d8d test: adapt tests to new features 2023-01-31 15:43:54 +01:00
Elias Schneider
3d5c919110 release: 0.9.0 2023-01-31 15:25:01 +01:00
Elias Schneider
008df06b5c feat: direct file link 2023-01-31 15:22:08 +01:00
Elias Schneider
cd9d828686 refactor: move guard checks to service 2023-01-31 13:53:23 +01:00
Elias Schneider
233c26e5cf fix: improve send test email UX 2023-01-31 13:16:11 +01:00
Elias Schneider
91a6b3f716 feat: file preview 2023-01-31 09:03:03 +01:00
Elias Schneider
0a2b7b1243 refactor: use cookie instead of local storage for share token 2023-01-26 21:18:22 +01:00
Elias Schneider
b98fe7911f release: 0.8.0 2023-01-26 16:10:16 +01:00
Elias Schneider
ad92cfc852 fix: admin users were created while the setup wizard wasn't finished 2023-01-26 15:43:13 +01:00
Elias Schneider
7e91038a24 chore: optimize prisma migration 2023-01-26 14:06:25 +01:00
Elias Schneider
4a5fb549c6 feat: reverse shares (#86)
* add first concept

* add reverse share funcionality to frontend

* allow creator to limit share expiration

* moved reverse share in seperate module

* add table to manage reverse shares

* delete complete share if reverse share was deleted

* optimize function names

* add db migration

* enable reverse share email notifications

* fix config variable descriptions

* fix migration for new installations
2023-01-26 13:44:04 +01:00
Elias Schneider
1ceb07b89e refactor: fix typo of service name 2023-01-17 09:48:49 +01:00
Elias Schneider
bb64f6c33f fix: Add meta tags to new pages 2023-01-17 09:13:53 +01:00
Elias Schneider
61c48d57b8 ci/cd: upgrade github actions 2023-01-13 15:37:49 +01:00
Luke
2a7587ed78 chore: docker compose ClamAV optimizations
* Update docker-compose.yml

Adds a depends_on clause that waits for clamav to be fulyl started before starting pingvin-share.

* Update README.md

Explains that it may take a minute or two for the app to start while it waits for clamav.

* minor refactoring

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-01-13 14:11:33 +01:00
245 changed files with 24424 additions and 5932 deletions

View File

@@ -0,0 +1,19 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language request: <language name in english>"
labels: [language-request]
body:
- type: input
id: language-name-native
attributes:
label: "🌐 Language name (native)"
placeholder: "Schweizerdeutsch"
validations:
required: true
- type: input
id: language-code
attributes:
label: "🌐 Language code"
placeholder: "de-CH"
validations:
required: true

17
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: ❓ Question
description: "Submit a question"
title: "❓ Question:"
labels: [question]
body:
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: "🙋‍♂️ Question"
description: "A clear question. Please provide as much detail as possible."
placeholder: "How do I ...?"
- type: markdown
attributes:
value: |
Before submitting, please check if the question hasn't been asked before.

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:18 container: node:18
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Install Dependencies - name: Install Dependencies
working-directory: ./backend working-directory: ./backend
run: npm install run: npm install

View File

@@ -1,4 +1,4 @@
name: Create Docker Image name: Build and Push Docker Image
on: on:
release: release:
@@ -9,16 +9,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 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: Login to Docker registry
- name: Build the image uses: docker/login-action@v2
run: | with:
docker buildx build --push \ username: ${{ secrets.DOCKER_USERNAME }}
--tag stonith404/pingvin-share:latest \ password: ${{ secrets.DOCKER_PASSWORD }}
--tag stonith404/pingvin-share:${{ github.ref_name }} \
--platform linux/amd64,linux/arm64 . - 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 }}

3
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-error.log*
# env file # env file
.env .env
!/backend/prisma/.env
# vercel # vercel
.vercel .vercel
@@ -39,4 +40,4 @@ yarn-error.log*
/data/ /data/
# Jetbrains specific (webstorm) # Jetbrains specific (webstorm)
.idea/**/** .idea/**/**

View File

@@ -1,3 +1,399 @@
## [0.22.2](https://github.com/stonith404/pingvin-share/compare/v0.22.1...v0.22.2) (2024-02-29)
### Bug Fixes
* extend access token cookie expiration ([013b988](https://github.com/stonith404/pingvin-share/commit/013b9886af5629b2ead6000b962267afc761c612))
* reduce refresh access token calls ([1aa3d8e](https://github.com/stonith404/pingvin-share/commit/1aa3d8e5e89b3696cc9554f41e9ce13806dde406))
* replace Nginx with Caddy to fix "premature close" error while downloading larger files ([43bff91](https://github.com/stonith404/pingvin-share/commit/43bff91db2ba4ec68d76e601f7bc42cb7a506bc5))
## [0.22.1](https://github.com/stonith404/pingvin-share/compare/v0.22.0...v0.22.1) (2024-02-18)
### Bug Fixes
* back links on error modals ([f52dffd](https://github.com/stonith404/pingvin-share/commit/f52dffdaac5a893804525913943f3f4f99b7c55a))
* prevent zoom on input field click on mobile ([9c734ec](https://github.com/stonith404/pingvin-share/commit/9c734ec439aeaeebe172caa41bf531e6d8b3fac3))
* replace middleware backend url with local backend url ([76df6f6](https://github.com/stonith404/pingvin-share/commit/76df6f66d965dd751146468abfafb0c6acd46310))
* user `id` and `totpVerified` can't be changed by user ([e663da4](https://github.com/stonith404/pingvin-share/commit/e663da45b1d15f5e6e33118e6a28e1504688034c))
* user enumaration on forgot password page ([64515d7](https://github.com/stonith404/pingvin-share/commit/64515d77cfc116a243d78610395ccc383ba62940))
## [0.22.0](https://github.com/stonith404/pingvin-share/compare/v0.21.5...v0.22.0) (2024-02-04)
### Bug Fixes
* **translations:** typo in string ([c189cd9](https://github.com/stonith404/pingvin-share/commit/c189cd97a502cee8ea79e5187d9288d636d4983c))
## [0.21.5](https://github.com/stonith404/pingvin-share/compare/v0.21.4...v0.21.5) (2024-01-14)
### Bug Fixes
* password can be changed with wrong password ([0ccb836](https://github.com/stonith404/pingvin-share/commit/0ccb8364448d27ea07c8b11972ff454d610893c6))
## [0.21.4](https://github.com/stonith404/pingvin-share/compare/v0.21.3...v0.21.4) (2024-01-09)
### Features
* **frontend:** add navigateToLink button for CopyTextField. close [#372](https://github.com/stonith404/pingvin-share/issues/372). ([#376](https://github.com/stonith404/pingvin-share/issues/376)) ([d775008](https://github.com/stonith404/pingvin-share/commit/d7750086b5b796cfc70d8dc0c7d0ab4bd1996ca0))
## [0.21.3](https://github.com/stonith404/pingvin-share/compare/v0.21.2...v0.21.3) (2024-01-02)
### Bug Fixes
* don't show validation error on upload modal if password or max views are empty ([fe09d0e](https://github.com/stonith404/pingvin-share/commit/fe09d0e25f6fbfc4e1c9302054d3387fe8b1f0ea))
## [0.21.2](https://github.com/stonith404/pingvin-share/compare/v0.21.1...v0.21.2) (2023-12-29)
### Bug Fixes
* missing logo images on fresh installation ([6fb31ab](https://github.com/stonith404/pingvin-share/commit/6fb31abd84b22cd464b6b45bf7ca6f83853e8720))
* missing translations on reset password page ([7a301b4](https://github.com/stonith404/pingvin-share/commit/7a301b455cdea4b1dbc04cc6223e094fee9aca7b))
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
### Features
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
### Features
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
### Bug Fixes
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
### Bug Fixes
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
### Bug Fixes
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
### Features
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
### Features
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
### Bug Fixes
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
### Bug Fixes
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
### Features
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
### Bug Fixes
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
### Bug Fixes
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
### Bug Fixes
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
### Features
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
### Bug Fixes
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
### Features
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
### Bug Fixes
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
### Bug Fixes
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
### Bug Fixes
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
### Bug Fixes
* `ECONNREFUSED` with Docker ipv6 enabled ([c9a2a46](https://github.com/stonith404/pingvin-share/commit/c9a2a469c67d3c3cd08179b44e2bf82208f05177))
## [0.17.1](https://github.com/stonith404/pingvin-share/compare/v0.17.0...v0.17.1) (2023-07-30)
### Bug Fixes
* rename pt-PT.ts to pt-BR.ts ([2584bb0](https://github.com/stonith404/pingvin-share/commit/2584bb0d48c761940eafc03d5cd98d47e7a5b0ae))
## [0.17.0](https://github.com/stonith404/pingvin-share/compare/v0.16.1...v0.17.0) (2023-07-23)
### Features
* ability to define zip compression level ([7827b68](https://github.com/stonith404/pingvin-share/commit/7827b687fa022e86a2643e7a1951af8c7e80608c))
* add note to language picker ([7f0c31c](https://github.com/stonith404/pingvin-share/commit/7f0c31c2e09b3ee9aae6c3dfb54fac2f2b1dfe23))
* add share url alias `/s` ([231a2e9](https://github.com/stonith404/pingvin-share/commit/231a2e95b9734cf4704454e1945698753dbb378b))
* localization ([#196](https://github.com/stonith404/pingvin-share/issues/196)) ([b9f6e3b](https://github.com/stonith404/pingvin-share/commit/b9f6e3bd08dcfc050048fba582b35958bc7b6184))
* update default value of `maxSize` from `1073741824` to `1000000000` ([389dc87](https://github.com/stonith404/pingvin-share/commit/389dc87cac775d916d0cff9b71d3c5ff90bfe916))
### Bug Fixes
* confusion between GB and GiB ([5816b39](https://github.com/stonith404/pingvin-share/commit/5816b39fc6ef6fe6b7cf8e7925aa297561f5b796))
* mistakes in English translations ([70b425b](https://github.com/stonith404/pingvin-share/commit/70b425b3807be79a3b518cc478996c71dffcf986))
* wrong layout if button text is too long in modals ([f4c88ae](https://github.com/stonith404/pingvin-share/commit/f4c88aeb0823c2c18535c25fcf8e16afa8b53a56))
### [0.16.1](https://github.com/stonith404/pingvin-share/compare/v0.16.0...v0.16.1) (2023-07-10)
### Features
* Adding reverse share ability to copy the link ([#191](https://github.com/stonith404/pingvin-share/issues/191)) ([7574eb3](https://github.com/stonith404/pingvin-share/commit/7574eb3191f21aadd64f436e9e7c78d3e3973a07)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
* Adding reverse shares' shares a clickable link ([#190](https://github.com/stonith404/pingvin-share/issues/190)) ([0276294](https://github.com/stonith404/pingvin-share/commit/0276294f5219a7edcc762bc52391b6720cfd741d))
### Bug Fixes
* set link default value to random ([#192](https://github.com/stonith404/pingvin-share/issues/192)) ([a1ea7c0](https://github.com/stonith404/pingvin-share/commit/a1ea7c026594a54eafd52f764eecbf06e1bb4d4e)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
## [0.16.0](https://github.com/stonith404/pingvin-share/compare/v0.15.0...v0.16.0) (2023-07-09)
### Features
* Adding more informations on My Shares page (table and modal) ([#174](https://github.com/stonith404/pingvin-share/issues/174)) ([1466240](https://github.com/stonith404/pingvin-share/commit/14662404614f15bc25384d924d8cb0458ab06cd8))
* Adding the possibility of copying the link by clicking text and icons ([#171](https://github.com/stonith404/pingvin-share/issues/171)) ([348852c](https://github.com/stonith404/pingvin-share/commit/348852cfa4275f5c642669b43697f83c35333044))
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
### Features
* add env variables for port, database url and data dir ([98c0de7](https://github.com/stonith404/pingvin-share/commit/98c0de78e8a73e3e5bf0928226cfb8a024b566a1))
* add healthcheck endpoint ([5132d17](https://github.com/stonith404/pingvin-share/commit/5132d177b8ab4e00a7e701e9956222fa2352d42c))
* allow to configure clamav with environment variables ([1df5c71](https://github.com/stonith404/pingvin-share/commit/1df5c7123e4ca8695f4f1b7d49f46cdf147fb920))
* configure ports, db url and api url with env variables ([e5071cb](https://github.com/stonith404/pingvin-share/commit/e5071cba1204093197b72e18d024b484e72e360a))
### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07)
### Bug Fixes
* boolean config variables can't be set to false ([39a7451](https://github.com/stonith404/pingvin-share/commit/39a74510c1f00466acaead39f7bee003b3db60d7))
## [0.14.0](https://github.com/stonith404/pingvin-share/compare/v0.13.1...v0.14.0) (2023-04-01)
### Features
* **share, config:** more variables, placeholder and reset default ([#132](https://github.com/stonith404/pingvin-share/issues/132)) ([beece56](https://github.com/stonith404/pingvin-share/commit/beece56327da141c222fd9f5259697df6db9347a))
### Bug Fixes
* bool config variable can't be changed ([0e5c673](https://github.com/stonith404/pingvin-share/commit/0e5c67327092e4751208e559a2b0d5ee2b91b6e3))
### [0.13.1](https://github.com/stonith404/pingvin-share/compare/v0.13.0...v0.13.1) (2023-03-14)
### Bug Fixes
* empty file can't be uploaded in chrome ([9f2097e](https://github.com/stonith404/pingvin-share/commit/9f2097e788dfb79c2f95085025934c3134a3eb38))
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
### Features
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
### Bug Fixes
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
### Bug Fixes
* 48px icon does not update ([753dbe8](https://github.com/stonith404/pingvin-share/commit/753dbe83b770814115a2576c7a50e1bac9dc8ce1))
## [0.12.0](https://github.com/stonith404/pingvin-share/compare/v0.11.1...v0.12.0) (2023-03-10)
### Features
* ability to change logo in frontend ([8403d7e](https://github.com/stonith404/pingvin-share/commit/8403d7e14ded801c3842a9b3fd87c3f6824c519e))
### Bug Fixes
* crypto is not defined ([8f71fd3](https://github.com/stonith404/pingvin-share/commit/8f71fd343506506532c1a24a4c66a16b1021705f))
* home page shown even if disabled ([3ad6b03](https://github.com/stonith404/pingvin-share/commit/3ad6b03b6bd80168870049582683077b689fa548))
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
### Bug Fixes
* old config variable prevents to create a share ([8b77e81](https://github.com/stonith404/pingvin-share/commit/8b77e81d4c1b8a2bf798595f5a66079c40734e09))
## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04)
### Features
* custom branding ([#112](https://github.com/stonith404/pingvin-share/issues/112)) ([fddad3e](https://github.com/stonith404/pingvin-share/commit/fddad3ef708c27052a8bf46f3076286d102f6d7e))
* invite new user with email ([f984050](https://github.com/stonith404/pingvin-share/commit/f9840505b82fcb04364a79576f186b76cc75f5c0))
### Bug Fixes
* frontend error when user deleted ([0317f3a](https://github.com/stonith404/pingvin-share/commit/0317f3a508dc88ffe2c33413704f7df03a2372ea))
### [0.10.2](https://github.com/stonith404/pingvin-share/compare/v0.10.1...v0.10.2) (2023-02-13)
### Bug Fixes
* pdf preview tries to render on server ([c3af0fe](https://github.com/stonith404/pingvin-share/commit/c3af0fe097582f69b63ed1ad18fb71bff334d32a))
### [0.10.1](https://github.com/stonith404/pingvin-share/compare/v0.10.0...v0.10.1) (2023-02-12)
### Bug Fixes
* non administrator user redirection error while setup isn't finished ([dc8cf3d](https://github.com/stonith404/pingvin-share/commit/dc8cf3d5ca6b4f8a8f243b8e0b05e09738cf8b61))
* setup wizard doesn't redirect after completion ([7cd9dff](https://github.com/stonith404/pingvin-share/commit/7cd9dff637900098c9f6e46ccade37283d47321b))
## [0.10.0](https://github.com/stonith404/pingvin-share/compare/v0.9.0...v0.10.0) (2023-02-10)
### ⚠ BREAKING CHANGES
* reset password with email
### Features
* allow multiple shares with one reverse share link ([ccdf8ea](https://github.com/stonith404/pingvin-share/commit/ccdf8ea3ae1e7b8520c5b1dd9bea18b1b3305f35))
* **frontend:** server side rendering to improve performance ([38de022](https://github.com/stonith404/pingvin-share/commit/38de022215a9b99c2eb36654f8dbb1e17ca87aba))
* reset password with email ([5d1a7f0](https://github.com/stonith404/pingvin-share/commit/5d1a7f0310df2643213affd2a0d1785b7e0af398))
### Bug Fixes
* delete all shares of reverse share ([86a7379](https://github.com/stonith404/pingvin-share/commit/86a737951951c911abd7967d76cb253c4335cb0c))
* invalid redirection after jwt expiry ([82f204e](https://github.com/stonith404/pingvin-share/commit/82f204e8a93e3113dcf65b1881d4943a898602eb))
* setup status doesn't change ([064ef38](https://github.com/stonith404/pingvin-share/commit/064ef38d783b3f351535c2911eb451efd9526d71))
* share creation without reverseShareToken ([b966270](https://github.com/stonith404/pingvin-share/commit/b9662701c42fe6771c07acb869564031accb2932))
* share fails if a share was created with a reverse share link recently ([edc10b7](https://github.com/stonith404/pingvin-share/commit/edc10b72b7884c629a8417c3c82222b135ef7653))
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
### Features
* direct file link ([008df06](https://github.com/stonith404/pingvin-share/commit/008df06b5cf48872d4dd68df813370596a4fd468))
* file preview ([91a6b3f](https://github.com/stonith404/pingvin-share/commit/91a6b3f716d37d7831e17a7be1cdb35cb23da705))
### Bug Fixes
* improve send test email UX ([233c26e](https://github.com/stonith404/pingvin-share/commit/233c26e5cfde59e7d51023ef9901dec2b84a4845))
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
### Features
* reverse shares ([#86](https://github.com/stonith404/pingvin-share/issues/86)) ([4a5fb54](https://github.com/stonith404/pingvin-share/commit/4a5fb549c6ac808261eb65d28db69510a82efd00))
### Bug Fixes
* Add meta tags to new pages ([bb64f6c](https://github.com/stonith404/pingvin-share/commit/bb64f6c33fc5c5e11f2c777785c96a74b57dfabc))
* admin users were created while the setup wizard wasn't finished ([ad92cfc](https://github.com/stonith404/pingvin-share/commit/ad92cfc852ca6aa121654d747a02628492ae5b89))
## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13) ## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13)

View File

@@ -1,3 +1,7 @@
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
---
# Contributing # Contributing
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more. We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
@@ -8,62 +12,55 @@ You've found a bug, have suggestion or something else, just create an issue on G
## Submit a Pull Request ## Submit a Pull Request
Once you created a issue and you want to create a pull request, follow this guide. Before you submit the pull request for review please ensure that
Branch naming convention is as following - The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
`TYPE-ISSUE_ID-DESCRIPTION` `<type>[optional scope]: <description>`
example: example:
```
feat(share): add password protection
```
When `TYPE` can be:
- **feat** - is a new feature
- **doc** - documentation only changes
- **fix** - a bug fix
- **refactor** - code change that neither fixes a bug nor adds a feature
- Your pull request has a detailed description
- You run `npm run format` to format the code
<details>
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
2. Clone your fork to your machine with `git clone`
``` ```
feat-69-ability-to-set-share-expiration-to-never $ git clone https://github.com/[your_username]/pingvin-share
```
When `TYPE` can be:
- **feat** - is a new feature
- **doc** - documentation only changes
- **fix** - a bug fix
- **refactor** - code change that neither fixes a bug nor adds a feature
**All PRs must include a commit message with the changes description!**
For the initial start, fork the project and use the `git clone` command to download the repository to your computer. A standard procedure for working on an issue would be to:
1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date.
```
$ git pull
```
2. Create new branch from `main` like: `feat-69-ability-to-set-share-expiration-to-never`<br/>
```
$ git checkout -b [name_of_your_new_branch]
``` ```
3. Work - commit - repeat 3. Work - commit - repeat
4. Before you push your changes, make sure you run the linter and format the code. 4. Push changes to GitHub
```bash
npm run lint
npm run format
```
5. Push changes to GitHub
``` ```
$ git push origin [name_of_your_new_branch] $ git push origin [name_of_your_new_branch]
``` ```
6. Submit your changes for review 5. Submit your changes for review
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
7. Start a Pull Request 6. Start a Pull Request
Now submit the pull request and click on `Create pull request`. 7. Now submit the pull request and click on `Create pull request`.
8. Get a code review approval/reject 8. Get a code review approval/reject
</details>
## Setup project ## Setup project
Pingvin Share consists of a frontend and a backend. Pingvin Share consists of a frontend and a backend.

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,44 +1,47 @@
# Using node slim because prisma ORM needs libc for ARM builds # Stage 1: Frontend dependencies
FROM node:20-alpine AS frontend-dependencies
# Stage 1: on frontend dependency change
FROM node:18-slim AS frontend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 2: on frontend change # Stage 2: Build frontend
FROM node:18-slim AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /opt/app WORKDIR /opt/app
COPY ./frontend . COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build RUN npm run build
# Stage 3: on backend dependency change # Stage 3: Backend dependencies
FROM node:18-slim AS backend-dependencies FROM node:20-alpine AS backend-dependencies
RUN apk add --no-cache python3
WORKDIR /opt/app WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 4:on backend change # Stage 4: Build backend
FROM node:18-slim AS backend-builder FROM node:20-alpine AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app WORKDIR /opt/app
COPY ./backend . COPY ./backend .
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
RUN npx prisma generate RUN npx prisma generate
RUN npm run build && npm prune --production RUN npm run build && npm prune --production
# Stage 5: Final image # Stage 5: Final image
FROM node:18-slim AS runner FROM node:20-alpine AS runner
ENV NODE_ENV=docker ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y 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 WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public COPY --from=frontend-builder /opt/app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=frontend-builder /opt/app/.next/standalone ./ COPY --from=frontend-builder /opt/app/.next/standalone ./
COPY --from=frontend-builder /opt/app/.next/static ./.next/static COPY --from=frontend-builder /opt/app/.next/static ./.next/static
COPY --from=frontend-builder /opt/app/public/img /tmp/img
WORKDIR /opt/app/backend WORKDIR /opt/app/backend
COPY --from=backend-builder /opt/app/node_modules ./node_modules COPY --from=backend-builder /opt/app/node_modules ./node_modules
@@ -47,5 +50,11 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
COPY --from=backend-builder /opt/app/package.json ./ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app WORKDIR /opt/app
EXPOSE 3000 EXPOSE 3000
CMD node frontend/server.js & cd backend && npm run prod
# Health check remains unchanged
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
# 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

142
README.md
View File

@@ -1,54 +1,166 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div> # <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
---
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer. Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
## ✨ Features ## ✨ Features
- Create a share with files that you can access with a link - Share files using a link
- No file size limit, only your disk will be your limit - Unlimited file size (restricted only by disk space)
- Set a share expiration - Set an expiration date for shares
- Optionally secure your share with a visitor limit and a password - Secure shares with visitor limits and passwords
- Email recepients - Email recipients
- ClamAV integration - Integration with ClamAV for security scans
## 🐧 Get to know Pingvin Share ## 🐧 Get to know Pingvin Share
- [Demo](https://pingvin-share.dev.eliasschneider.com) - [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA) - [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/> <img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ Setup ## ⌨️ Setup
> Pleas note that Pingvin Share is in early stage and could include some bugs > Note: Pingvin Share is in its early stages and may contain bugs.
### Recommended installation ### Installation with Docker (recommended)
1. Download the `docker-compose.yml` file 1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d` 2. Run `docker-compose up -d`
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧! The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Stand-alone Installation
Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
```bash
git clone https://github.com/stonith404/pingvin-share
cd pingvin-share
# Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend
cd backend
npm install
npm run build
pm2 start --name="pingvin-share-backend" npm -- run prod
# Start the frontend
cd ../frontend
npm install
npm run build
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
pm2 start --name="pingvin-share-frontend" npm -- run start
```
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./Caddyfile`.
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Integrations ### Integrations
#### ClamAV #### ClamAV (Docker only)
With ClamAV the shares get scanned for malicious files and get removed if any found. ClamAV is used to scan shares for malicious files and remove them if found.
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container. 1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
2. As soon as the ClamAV container is ready (when ClamAV logs "socket found, clamd started"), restart the Pingvin Share container with `docker compose restart pingvin-share` 2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
3. The Pingvin Share logs should now log "ClamAV is active" 3. The Pingvin Share logs should now log "ClamAV is active"
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements). 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 ### Additional resources
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) - [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
### Upgrade to a new version ### Upgrade to a new version
Run `docker compose pull && docker compose up -d` to update your docker container As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
#### Docker
```bash
docker compose pull
docker compose up -d
```
#### Stand-alone
1. Stop the running app
```bash
pm2 stop pingvin-share-backend pingvin-share-frontend
```
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
```bash
cd pingvin-share
# Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend
cd backend
npm run build
pm2 restart pingvin-share-backend
# Start the frontend
cd ../frontend
npm run build
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
pm2 restart pingvin-share-frontend
```
### Configuration
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
#### Environment variables
For installation specific configuration, you can use environment variables. The following variables are available:
##### Backend
| Variable | Default Value | Description |
| ---------------- | -------------------------------------------------- | -------------------------------------- |
| `PORT` | `8080` | The port on which the backend listens. |
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
##### Frontend
| Variable | Default Value | Description |
| --------- | ----------------------- | ---------------------------------------- |
| `PORT` | `3000` | The port on which the frontend listens. |
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
## 🖤 Contribute ## 🖤 Contribute
You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started. ### Translations
You can help to translate Pingvin Share into your language.
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
### Project
You're very welcome to contribute to Pingvin Share! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.

1
backend/.prettierignore Normal file
View File

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

View File

@@ -1,22 +0,0 @@
FROM node:18 AS deps
WORKDIR /opt/app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
RUN npx prisma generate
FROM node:18 As build
WORKDIR /opt/app
COPY . .
COPY --from=deps /opt/app/node_modules ./node_modules
RUN npm run build
FROM node:18 As runner
WORKDIR /opt/app
COPY --from=build /opt/app/node_modules ./node_modules
COPY --from=build /opt/app/dist ./dist
COPY --from=build /opt/app/prisma ./prisma
COPY --from=deps /opt/app/package.json ./
CMD npm run prod

View File

@@ -1,5 +1,8 @@
{ {
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
} }

5412
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +1,85 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.7.0", "version": "0.22.2",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "nest start --watch", "dev": "cross-env NODE_ENV=development nest start --watch",
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main", "prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --end-of-line=auto --write 'src/**/*.ts'",
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json" "test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
}, },
"prisma": { "prisma": {
"seed": "ts-node prisma/seed/config.seed.ts" "seed": "ts-node prisma/seed/config.seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^9.2.1", "@nestjs/cache-manager": "^2.1.0",
"@nestjs/config": "^2.2.0", "@nestjs/common": "^10.1.2",
"@nestjs/core": "^9.2.1", "@nestjs/config": "^3.0.0",
"@nestjs/jwt": "^10.0.1", "@nestjs/core": "^10.1.2",
"@nestjs/mapped-types": "^1.2.0", "@nestjs/jwt": "^10.1.0",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^10.1.2",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^3.0.1",
"@nestjs/throttler": "^3.1.0", "@nestjs/swagger": "^7.1.4",
"@prisma/client": "^4.8.1", "@nestjs/throttler": "^4.2.1",
"@prisma/client": "^5.0.0",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"body-parser": "^1.20.1", "body-parser": "^1.20.2",
"cache-manager": "^5.2.4",
"clamscan": "^2.1.2", "clamscan": "^2.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.14.0",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"nodemailer": "^6.9.0", "nanoid": "^3.3.6",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.4",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0", "qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^4.0.4", "rimraf": "^5.0.1",
"rxjs": "^7.8.0", "rxjs": "^7.8.1",
"sharp": "^0.32.4",
"ts-node": "^10.9.1" "ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.8", "@nestjs/cli": "^10.1.10",
"@nestjs/schematics": "^9.0.4", "@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^10.1.2",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.2",
"@types/clamscan": "^2.0.4", "@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.1",
"@types/express": "^4.17.15", "@types/express": "^4.17.17",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/node": "^18.11.18", "@types/multer": "^1.4.7",
"@types/nodemailer": "^6.4.7", "@types/node": "^20.4.5",
"@types/passport-jwt": "^3.0.8", "@types/node-fetch": "^2.6.6",
"@types/nodemailer": "^6.4.9",
"@types/passport-jwt": "^3.0.9",
"@types/qrcode-svg": "^1.1.1", "@types/qrcode-svg": "^1.1.1",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^6.2.0",
"@typescript-eslint/parser": "^5.48.1", "@typescript-eslint/parser": "^6.2.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.31.0", "eslint": "^8.46.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.9.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^5.0.0",
"newman": "^5.3.2", "newman": "^5.3.2",
"prettier": "^2.8.2", "prettier": "^3.0.0",
"prisma": "^4.8.1", "prisma": "^5.0.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.4",
"tsconfig-paths": "4.1.2", "tsconfig-paths": "4.2.0",
"typescript": "^4.9.4", "typescript": "^5.1.6",
"wait-on": "^7.0.1" "wait-on": "^7.0.1"
} }
} }

2
backend/prisma/.env Normal file
View File

@@ -0,0 +1,2 @@
#This file is only used to set a default value for the database url
DATABASE_URL="file:../data/pingvin-share.db"

View File

@@ -0,0 +1,67 @@
/*
Warnings:
- Added the required column `order` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"creatorId" TEXT NOT NULL,
"shareId" TEXT,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ReverseShare_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL
);
INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "order") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", 0 FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
-- CreateIndex
CREATE UNIQUE INDEX "ReverseShare_shareId_key" ON "ReverseShare"("shareId");
-- Custom migration
UPDATE Config SET `order` = 0 WHERE key = "JWT_SECRET";
UPDATE Config SET `order` = 0 WHERE key = "TOTP_SECRET";
UPDATE Config SET `order` = 1 WHERE key = "APP_URL";
UPDATE Config SET `order` = 2 WHERE key = "SHOW_HOME_PAGE";
UPDATE Config SET `order` = 3 WHERE key = "ALLOW_REGISTRATION";
UPDATE Config SET `order` = 4 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE Config SET `order` = 5 WHERE key = "MAX_SHARE_SIZE";
UPDATE Config SET `order` = 6, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE Config SET `order` = 7, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE";
UPDATE Config SET `order` = 8, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT";
UPDATE Config SET `order` = 12 WHERE key = "SMTP_HOST";
UPDATE Config SET `order` = 13 WHERE key = "SMTP_PORT";
UPDATE Config SET `order` = 14 WHERE key = "SMTP_EMAIL";
UPDATE Config SET `order` = 15 WHERE key = "SMTP_USERNAME";
UPDATE Config SET `order` = 16 WHERE key = "SMTP_PASSWORD";
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (11, "SMTP_ENABLED", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "boolean", IFNULL((SELECT value FROM Config WHERE key="ENABLE_SHARE_EMAIL_RECIPIENTS"), "false"), "smtp", 0, strftime('%s', 'now'));
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`, `locked`) VALUES (0, "SETUP_STATUS", "Status of the setup wizard", "string", IIF((SELECT value FROM Config WHERE key="SETUP_FINISHED") == "true", "FINISHED", "STARTED"), "internal", 0, strftime('%s', 'now'), 1);

View File

@@ -0,0 +1,64 @@
/*
Warnings:
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
PRAGMA foreign_keys=OFF;
CREATE TABLE "ResetPasswordToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Disable TOTP as secret isn't encrypted anymore
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
-- RedefineTables
CREATE TABLE "new_Share" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
"views" INTEGER NOT NULL DEFAULT 0,
"expiration" DATETIME NOT NULL,
"description" TEXT,
"removedReason" TEXT,
"creatorId" TEXT,
"reverseShareId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
CREATE TABLE "new_ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"remainingUses" INTEGER NOT NULL,
"creatorId" TEXT NOT NULL,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare";
DROP TABLE "ReverseShare";
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");

View File

@@ -0,0 +1,94 @@
/*
Warnings:
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL,
PRIMARY KEY ("name", "category")
);
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT,
"defaultValue" TEXT NOT NULL DEFAULT '',
"description" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL,
PRIMARY KEY ("name", "category")
);
INSERT INTO "new_Config" ("category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,27 @@
/*
Warnings:
- You are about to drop the column `description` on the `Config` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"type" TEXT NOT NULL,
"defaultValue" TEXT NOT NULL DEFAULT '',
"value" TEXT,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL,
PRIMARY KEY ("name", "category")
);
INSERT INTO "new_Config" ("category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

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

@@ -4,7 +4,7 @@ generator client {
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:../data/pingvin-share.db" url = env("DATABASE_URL")
} }
model User { model User {
@@ -14,16 +14,20 @@ model User {
username String @unique username String @unique
email String @unique email String @unique
password String password String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
shares Share[] shares Share[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
loginTokens LoginToken[] loginTokens LoginToken[]
reverseShares ReverseShare[]
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
totpVerified Boolean @default(false) totpVerified Boolean @default(false)
totpSecret String? totpSecret String?
resetPasswordToken ResetPasswordToken?
oAuthUsers OAuthUser[]
} }
model RefreshToken { model RefreshToken {
@@ -48,6 +52,25 @@ model LoginToken {
used Boolean @default(false) used Boolean @default(false)
} }
model ResetPasswordToken {
token String @id @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model OAuthUser {
id String @id @default(uuid())
provider String
providerUserId String
providerUsername String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Share { model Share {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -59,13 +82,33 @@ model Share {
description String? description String?
removedReason String? removedReason String?
creatorId String? creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity? security ShareSecurity?
recipients ShareRecipient[] recipients ShareRecipient[]
files File[] files File[]
} }
model ReverseShare {
id String @id @default(uuid())
createdAt DateTime @default(now())
token String @unique @default(uuid())
shareExpiration DateTime
maxShareSize String
sendEmailNotification Boolean
remainingUses Int
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shares Share[]
}
model ShareRecipient { model ShareRecipient {
id String @id @default(uuid()) id String @id @default(uuid())
email String email String
@@ -99,12 +142,15 @@ model ShareSecurity {
model Config { model Config {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
key String @id name String
type String category String
value String type String
description String defaultValue String @default("")
category String value String?
obscured Boolean @default(false) obscured Boolean @default(false)
secret Boolean @default(true) secret Boolean @default(true)
locked Boolean @default(false) locked Boolean @default(false)
order Int
@@id([name, category])
} }

View File

@@ -1,183 +1,316 @@
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto"; import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [ const configVariables: ConfigVariables = {
{ internal: {
key: "SETUP_FINISHED", jwtSecret: {
description: "Whether the setup has been finished", type: "string",
type: "boolean", value: crypto.randomBytes(256).toString("base64"),
value: "false", locked: true,
category: "internal", },
secret: false,
locked: true,
}, },
{ general: {
key: "APP_URL", appName: {
description: "On which URL Pingvin Share is available", type: "string",
type: "string", defaultValue: "Pingvin Share",
value: "http://localhost:3000", secret: false,
category: "general", },
secret: false, appUrl: {
type: "string",
defaultValue: "http://localhost:3000",
secret: false,
},
showHomePage: {
type: "boolean",
defaultValue: "true",
secret: false,
},
}, },
{ share: {
key: "SHOW_HOME_PAGE", allowRegistration: {
description: "Whether to show the home page", type: "boolean",
type: "boolean", defaultValue: "true",
value: "true", secret: false,
category: "general", },
secret: false, allowUnauthenticatedShares: {
type: "boolean",
defaultValue: "false",
secret: false,
},
maxExpiration: {
type: "number",
defaultValue: "0",
secret: false,
},
maxSize: {
type: "number",
defaultValue: "1000000000",
secret: false,
},
zipCompressionLevel: {
type: "number",
defaultValue: "9",
},
}, },
{ email: {
key: "ALLOW_REGISTRATION", enableShareEmailRecipients: {
description: "Whether registration is allowed", type: "boolean",
type: "boolean", defaultValue: "false",
value: "true",
category: "share",
secret: false,
},
{
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
key: "MAX_SHARE_SIZE",
description: "Maximum share size in bytes",
type: "number",
value: "1073741824",
category: "share",
secret: false,
},
{
key: "JWT_SECRET",
description: "Long random string used to sign JWT tokens",
type: "string",
value: crypto.randomBytes(256).toString("base64"),
category: "internal",
locked: true,
},
{
key: "TOTP_SECRET",
description: "A 16 byte random string used to generate TOTP secrets",
type: "string",
value: crypto.randomBytes(16).toString("base64"),
category: "internal",
locked: true,
},
{
key: "ENABLE_EMAIL_RECIPIENTS",
description:
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "boolean",
value: "false",
category: "email",
secret: false,
},
{
key: "EMAIL_MESSAGE",
description:
"Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
key: "EMAIL_SUBJECT",
description: "Subject of the email which gets sent to the recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{
key: "SMTP_HOST",
description: "Host of the SMTP server",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_PORT",
description: "Port of the SMTP server",
type: "number",
value: "0",
category: "email",
},
{
key: "SMTP_EMAIL",
description: "Email address which the emails get sent from",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
category: "email",
},
{
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
category: "email",
},
];
const prisma = new PrismaClient(); secret: false,
},
shareRecipientsSubject: {
type: "string",
defaultValue: "Files shared with you",
},
shareRecipientsMessage: {
type: "text",
defaultValue:
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
},
reverseShareSubject: {
type: "string",
defaultValue: "Reverse share link used",
},
reverseShareMessage: {
type: "text",
defaultValue:
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
},
resetPasswordSubject: {
type: "string",
defaultValue: "Pingvin Share password reset",
},
resetPasswordMessage: {
type: "text",
defaultValue:
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
},
inviteSubject: {
type: "string",
defaultValue: "Pingvin Share invite",
},
inviteMessage: {
type: "text",
defaultValue:
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
},
},
smtp: {
enabled: {
type: "boolean",
defaultValue: "false",
secret: false,
},
host: {
type: "string",
defaultValue: "",
},
port: {
type: "number",
defaultValue: "0",
},
email: {
type: "string",
defaultValue: "",
},
username: {
type: "string",
defaultValue: "",
},
password: {
type: "string",
defaultValue: "",
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,
},
}
};
async function main() { type ConfigVariables = {
for (const variable of configVariables) { [category: string]: {
const existingConfigVariable = await prisma.config.findUnique({ [variable: string]: Omit<
where: { key: variable.key }, Prisma.ConfigCreateInput,
}); "name" | "category" | "order"
>;
};
};
// Create a new config variable if it doesn't exist const prisma = new PrismaClient({
if (!existingConfigVariable) { datasources: {
await prisma.config.create({ db: {
data: variable, url:
process.env.DATABASE_URL ||
"file:../data/pingvin-share.db?connection_limit=1",
},
},
});
async function seedConfigVariables() {
for (const [category, configVariablesOfCategory] of Object.entries(
configVariables
)) {
let order = 0;
for (const [name, properties] of Object.entries(
configVariablesOfCategory
)) {
const existingConfigVariable = await prisma.config.findUnique({
where: { name_category: { name, category } },
}); });
// Create a new config variable if it doesn't exist
if (!existingConfigVariable) {
await prisma.config.create({
data: {
order,
name,
...properties,
category,
},
});
}
order++;
} }
} }
}
const configVariablesFromDatabase = await prisma.config.findMany(); async function migrateConfigVariables() {
const existingConfigVariables = await prisma.config.findMany();
// Delete the config variable if it doesn't exist anymore for (const existingConfigVariable of existingConfigVariables) {
for (const configVariableFromDatabase of configVariablesFromDatabase) { const configVariable =
const configVariable = configVariables.find( configVariables[existingConfigVariable.category]?.[
(v) => v.key == configVariableFromDatabase.key existingConfigVariable.name
); ];
if (!configVariable) { if (!configVariable) {
await prisma.config.delete({ await prisma.config.delete({
where: { key: configVariableFromDatabase.key }, where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
}); });
// Update the config variable if the metadata changed // Update the config variable if the metadata changed
} else if ( } else if (
JSON.stringify({ JSON.stringify({
...configVariable, ...configVariable,
key: configVariableFromDatabase.key, name: existingConfigVariable.name,
value: configVariableFromDatabase.value, category: existingConfigVariable.category,
}) != JSON.stringify(configVariableFromDatabase) value: existingConfigVariable.value,
}) != JSON.stringify(existingConfigVariable)
) { ) {
await prisma.config.update({ await prisma.config.update({
where: { key: configVariableFromDatabase.key }, where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
data: { data: {
...configVariable, ...configVariable,
key: configVariableFromDatabase.key, name: existingConfigVariable.name,
value: configVariableFromDatabase.value, category: existingConfigVariable.category,
value: existingConfigVariable.value,
}, },
}); });
} }
} }
} }
main()
seedConfigVariables()
.then(() => migrateConfigVariables())
.then(async () => { .then(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
}) })

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

@@ -12,7 +12,11 @@ import { JobsModule } from "./jobs/jobs.module";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
import { ShareModule } from "./share/share.module"; import { ShareModule } from "./share/share.module";
import { UserModule } from "./user/user.module"; import { UserModule } from "./user/user.module";
import { ClamscanModule } from "./clamscan/clamscan.module"; import { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
import { AppController } from "./app.controller";
import { OAuthModule } from "./oauth/oauth.module";
import { CacheModule } from "@nestjs/cache-manager";
@Module({ @Module({
imports: [ imports: [
@@ -29,8 +33,14 @@ import { ClamscanModule } from "./clamscan/clamscan.module";
limit: 100, limit: 100,
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ClamscanModule, ClamScanModule,
ReverseShareModule,
OAuthModule,
CacheModule.register({
isGlobal: true,
}),
], ],
controllers: [AppController],
providers: [ providers: [
{ {
provide: APP_GUARD, provide: APP_GUARD,

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
ForbiddenException, ForbiddenException,
HttpCode, HttpCode,
Param,
Patch, Patch,
Post, Post,
Req, Req,
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
import { TokenDTO } from "./dto/token.dto"; import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
@@ -31,80 +33,95 @@ export class AuthController {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private authTotpService: AuthTotpService, private authTotpService: AuthTotpService,
private config: ConfigService private config: ConfigService,
) {} ) {}
@Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
@Throttle(10, 5 * 60)
async signUp( async signUp(
@Body() dto: AuthRegisterDTO, @Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
if (!this.config.get("ALLOW_REGISTRATION")) if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
const result = await this.authService.signUp(dto); const result = await this.authService.signUp(dto);
response = this.addTokensToResponse( this.authService.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signIn( async signIn(
@Body() dto: AuthSignInDTO, @Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
const result = await this.authService.signIn(dto); const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) { if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse( this.authService.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
} }
return result; return result;
} }
@Throttle(10, 5 * 60)
@Post("signIn/totp") @Post("signIn/totp")
@Throttle(10, 5 * 60)
@HttpCode(200) @HttpCode(200)
async signInTotp( async signInTotp(
@Body() dto: AuthSignInTotpDTO, @Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
const result = await this.authTotpService.signInTotp(dto); const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse( this.authService.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
return new TokenDTO().from(result); return new TokenDTO().from(result);
} }
@Post("resetPassword/:email")
@Throttle(5, 5 * 60)
@HttpCode(202)
async requestResetPassword(@Param("email") email: string) {
this.authService.requestResetPassword(email);
}
@Post("resetPassword")
@Throttle(5, 5 * 60)
@HttpCode(204)
async resetPassword(@Body() dto: ResetPasswordDTO) {
return await this.authService.resetPassword(dto.token, dto.password);
}
@Patch("password") @Patch("password")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updatePassword( async updatePassword(
@GetUser() user: User, @GetUser() user: User,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() dto: UpdatePasswordDTO @Body() dto: UpdatePasswordDTO,
) { ) {
const result = await this.authService.updatePassword( const result = await this.authService.updatePassword(
user, user,
dto.password,
dto.oldPassword, dto.oldPassword,
dto.password
); );
response = this.addTokensToResponse(response, result.refreshToken); this.authService.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result); return new TokenDTO().from(result);
} }
@@ -112,21 +129,21 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
async refreshAccessToken( async refreshAccessToken(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
if (!request.cookies.refresh_token) throw new UnauthorizedException(); if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken( const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token request.cookies.refresh_token,
); );
response.cookie("access_token", accessToken); this.authService.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken }); return new TokenDTO().from({ accessToken });
} }
@Post("signOut") @Post("signOut")
async signOut( async signOut(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
await this.authService.signOut(request.cookies.access_token); await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 }); response.cookie("access_token", "accessToken", { maxAge: -1 });
@@ -155,20 +172,4 @@ export class AuthController {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code); return this.authTotpService.disableTotp(user, body.password, body.code);
} }
private addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string
) {
if (accessToken) response.cookie("access_token", accessToken);
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
return response;
}
} }

View File

@@ -1,12 +1,18 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service"; import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy"; import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({ @Module({
imports: [JwtModule.register({})], imports: [
JwtModule.register({
global: true,
}),
EmailModule,
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy], providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService], exports: [AuthService],

View File

@@ -6,10 +6,12 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2"; import * as argon from "argon2";
import { Request, Response } from "express";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
@@ -19,33 +21,36 @@ export class AuthService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService private config: ConfigService,
private emailService: EmailService,
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
const hash = await argon.hash(dto.password); const isFirstUser = (await this.prisma.user.count()) == 0;
const hash = dto.password ? await argon.hash(dto.password) : null;
try { try {
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
email: dto.email, email: dto.email,
username: dto.username, username: dto.username,
password: hash, password: hash,
isAdmin: !this.config.get("SETUP_FINISHED"), isAdmin: isFirstUser,
}, },
}); });
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id user.id,
); );
const accessToken = await this.createAccessToken(user, refreshTokenId); const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken, user };
} catch (e) { } catch (e) {
if (e instanceof PrismaClientKnownRequestError) { if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") { if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0]; const duplicatedField: string = e.meta.target[0];
throw new BadRequestException( throw new BadRequestException(
`A user with this ${duplicatedField} already exists` `A user with this ${duplicatedField} already exists`,
); );
} }
} }
@@ -65,25 +70,78 @@ export class AuthService {
if (!user || !(await argon.verify(user.password, dto.password))) if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or 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 // TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled // Check if the user has TOTP enabled
if (user.totpVerified) { if (
user.totpVerified &&
!(isOAuth && this.config.get("oauth.ignoreTotp"))
) {
const loginToken = await this.createLoginToken(user.id); const loginToken = await this.createLoginToken(user.id);
return { loginToken }; return { loginToken };
} }
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id user.id,
); );
const accessToken = await this.createAccessToken(user, refreshTokenId); const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
async updatePassword(user: User, oldPassword: string, newPassword: string) { async requestResetPassword(email: string) {
if (!(await argon.verify(user.password, oldPassword))) const user = await this.prisma.user.findFirst({
throw new ForbiddenException("Invalid password"); where: { email },
include: { resetPasswordToken: true },
});
if (!user) throw new BadRequestException("User not found");
// Delete old reset password token
if (user.resetPasswordToken) {
await this.prisma.resetPasswordToken.delete({
where: { token: user.resetPasswordToken.token },
});
}
const { token } = await this.prisma.resetPasswordToken.create({
data: {
expiresAt: moment().add(1, "hour").toDate(),
user: { connect: { id: user.id } },
},
});
await this.emailService.sendResetPasswordEmail(user.email, token);
}
async resetPassword(token: string, newPassword: string) {
const user = await this.prisma.user.findFirst({
where: { resetPasswordToken: { token } },
});
if (!user) throw new BadRequestException("Token invalid or expired");
const newPasswordHash = await argon.hash(newPassword);
await this.prisma.resetPasswordToken.delete({
where: { token },
});
await this.prisma.user.update({
where: { id: user.id },
data: { password: newPasswordHash },
});
}
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); const hash = await argon.hash(newPassword);
@@ -104,26 +162,30 @@ export class AuthService {
{ {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
isAdmin: user.isAdmin,
refreshTokenId, refreshTokenId,
}, },
{ {
expiresIn: "15min", expiresIn: "15min",
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
} },
); );
} }
async signOut(accessToken: string) { async signOut(accessToken: string) {
const { refreshTokenId } = this.jwtService.decode(accessToken) as { const { refreshTokenId } =
refreshTokenId: string; (this.jwtService.decode(accessToken) as {
}; refreshTokenId: string;
}) || {};
await this.prisma.refreshToken if (refreshTokenId) {
.delete({ where: { id: refreshTokenId } }) await this.prisma.refreshToken
.catch((e) => { .delete({ where: { id: refreshTokenId } })
// Ignore error if refresh token doesn't exist .catch((e) => {
if (e.code != "P2025") throw e; // Ignore error if refresh token doesn't exist
}); if (e.code != "P2025") throw e;
});
}
} }
async refreshAccessToken(refreshToken: string) { async refreshAccessToken(refreshToken: string) {
@@ -137,7 +199,7 @@ export class AuthService {
return this.createAccessToken( return this.createAccessToken(
refreshTokenMetaData.user, refreshTokenMetaData.user,
refreshTokenMetaData.id refreshTokenMetaData.id,
); );
} }
@@ -158,4 +220,41 @@ export class AuthService {
return loginToken; return loginToken;
} }
addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string,
) {
if (accessToken)
response.cookie("access_token", accessToken, {
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
});
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
});
}
/**
* 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

@@ -6,10 +6,8 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as argon from "argon2"; import * as argon from "argon2";
import * as crypto from "crypto";
import { authenticator, totp } from "otplib"; import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg"; import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -17,51 +15,34 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable() @Injectable()
export class AuthTotpService { export class AuthTotpService {
constructor( constructor(
private config: ConfigService,
private prisma: PrismaService, private prisma: PrismaService,
private authService: AuthService private authService: AuthService,
) {} ) {}
async signInTotp(dto: AuthSignInTotpDTO) { async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({ const token = await this.prisma.loginToken.findFirst({
where: { where: {
token: dto.loginToken, token: dto.loginToken,
}, },
include: {
user: true,
},
}); });
if (!token || token.userId != user.id || token.used) if (!token || token.used)
throw new UnauthorizedException("Invalid login token"); throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date()) if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired"); throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code // Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({ const { totpSecret } = token.user;
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) { if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password); if (!authenticator.check(dto.totp, totpSecret)) {
const expected = authenticator.generate(decryptedSecret);
if (dto.totp !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
} }
@@ -72,50 +53,15 @@ export class AuthTotpService {
}); });
const { refreshToken, refreshTokenId } = const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id); await this.authService.createRefreshToken(token.user.id);
const accessToken = await this.authService.createAccessToken( const accessToken = await this.authService.createAccessToken(
user, token.user,
refreshTokenId refreshTokenId,
); );
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
encryptTotpSecret(totpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(totpSecret);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString("base64");
}
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
let iv = this.config.get("TOTP_SECRET");
iv = Buffer.from(iv, "base64");
const key = crypto
.createHash("sha256")
.update(String(password))
.digest("base64")
.substr(0, 32);
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
async enableTotp(user: User, password: string) { async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password))) if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
@@ -130,21 +76,19 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is already enabled"); throw new BadRequestException("TOTP is already enabled");
} }
// TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
const encryptedSecret = this.encryptTotpSecret(secret, password);
const otpURL = totp.keyuri( const otpURL = totp.keyuri(
user.username || user.email, user.username || user.email,
"pingvin-share", "pingvin-share",
secret secret,
); );
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
totpEnabled: true, totpEnabled: true,
totpSecret: encryptedSecret, totpSecret: secret,
}, },
}); });
@@ -177,9 +121,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not in progress"); throw new BadRequestException("TOTP is not in progress");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");
@@ -208,9 +150,7 @@ export class AuthTotpService {
throw new BadRequestException("TOTP is not enabled"); throw new BadRequestException("TOTP is not enabled");
} }
const decryptedSecret = this.decryptTotpSecret(totpSecret, password); const expected = authenticator.generate(totpSecret);
const expected = authenticator.generate(decryptedSecret);
if (code !== expected) { if (code !== expected) {
throw new BadRequestException("Invalid code"); throw new BadRequestException("Invalid code");

View File

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

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends PickType(UserDTO, [ export class AuthRegisterDTO extends PickType(UserDTO, [

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator"; import { IsEmail, IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";

View File

@@ -1,18 +1,7 @@
import { PickType } from "@nestjs/mapped-types"; import { IsString } from "class-validator";
import { IsEmail, IsOptional, IsString } from "class-validator"; import { AuthSignInDTO } from "./authSignIn.dto";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInTotpDTO extends PickType(UserDTO, [
"password",
] as const) {
@IsEmail()
@IsOptional()
email: string;
@IsString()
@IsOptional()
username: string;
export class AuthSignInTotpDTO {
@IsString() @IsString()
totp: string; totp: string;

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {} export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator"; import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto"; import { UserDTO } from "src/user/dto/user.dto";

View File

@@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") {
try { try {
return (await super.canActivate(context)) as boolean; return (await super.canActivate(context)) as boolean;
} catch { } catch {
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES"); return this.config.get("share.allowUnauthenticatedShares");
} }
} }
} }

View File

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

View File

@@ -7,4 +7,4 @@ import { ClamScanService } from "./clamscan.service";
providers: [ClamScanService], providers: [ClamScanService],
exports: [ClamScanService], exports: [ClamScanService],
}) })
export class ClamscanModule {} export class ClamScanModule {}

View File

@@ -1,33 +1,35 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import * as NodeClam from "clamscan"; import * as NodeClam from "clamscan";
import * as fs from "fs"; import * as fs from "fs";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
const clamscanConfig = { const clamscanConfig = {
clamdscan: { clamdscan: {
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1", host: CLAMAV_HOST,
port: 3310, port: CLAMAV_PORT,
localFallback: false, localFallback: false,
}, },
preference: "clamdscan", preference: "clamdscan",
}; };
@Injectable() @Injectable()
export class ClamScanService { export class ClamScanService {
private readonly logger = new Logger(ClamScanService.name);
constructor( constructor(
private fileService: FileService, private fileService: FileService,
private prisma: PrismaService private prisma: PrismaService,
) {} ) {}
private ClamScan: Promise<NodeClam | null> = new NodeClam() private ClamScan: Promise<NodeClam | null> = new NodeClam()
.init(clamscanConfig) .init(clamscanConfig)
.then((res) => { .then((res) => {
console.log("ClamAV is active"); this.logger.log("ClamAV is active");
return res; return res;
}) })
.catch(() => { .catch(() => {
console.log("ClamAV is not active"); this.logger.log("ClamAV is not active");
return null; return null;
}); });
@@ -39,14 +41,14 @@ export class ClamScanService {
const infectedFiles = []; const infectedFiles = [];
const files = fs const files = fs
.readdirSync(`./data/uploads/shares/${shareId}`) .readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
.filter((file) => file != "archive.zip"); .filter((file) => file != "archive.zip");
for (const fileId of files) { for (const fileId of files) {
const { isInfected } = await clamScan const { isInfected } = await clamScan
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`) .isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
.catch(() => { .catch(() => {
console.log("ClamAV is not active"); this.logger.log("ClamAV is not active");
return { isInfected: false }; return { isInfected: false };
}); });
@@ -78,8 +80,8 @@ export class ClamScanService {
}, },
}); });
console.log( 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

@@ -1,4 +1,18 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
FileTypeValidator,
Get,
Param,
ParseFilePipe,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
@@ -7,37 +21,36 @@ import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto"; import { ConfigDTO } from "./dto/config.dto";
import { TestEmailDTO } from "./dto/testEmail.dto"; import { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto"; import UpdateConfigDTO from "./dto/updateConfig.dto";
import { LogoService } from "./logo.service";
@Controller("configs") @Controller("configs")
export class ConfigController { export class ConfigController {
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private emailService: EmailService private logoService: LogoService,
private emailService: EmailService,
) {} ) {}
@Get() @Get()
@SkipThrottle()
async list() { async list() {
return new ConfigDTO().fromList(await this.configService.list()); return new ConfigDTO().fromList(await this.configService.list());
} }
@Get("admin") @Get("admin/:category")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async listForAdmin() { async getByCategory(@Param("category") category: string) {
return new AdminConfigDTO().fromList( return new AdminConfigDTO().fromList(
await this.configService.listForAdmin() await this.configService.getByCategory(category),
); );
} }
@Patch("admin") @Patch("admin")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async updateMany(@Body() data: UpdateConfigDTO[]) { async updateMany(@Body() data: UpdateConfigDTO[]) {
await this.configService.updateMany(data); return new AdminConfigDTO().fromList(
} await this.configService.updateMany(data),
);
@Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() {
return await this.configService.finishSetup();
} }
@Post("admin/testEmail") @Post("admin/testEmail")
@@ -45,4 +58,18 @@ export class ConfigController {
async testEmail(@Body() { email }: TestEmailDTO) { async testEmail(@Body() { email }: TestEmailDTO) {
await this.emailService.sendTestMail(email); await this.emailService.sendTestMail(email);
} }
@Post("admin/logo")
@UseInterceptors(FileInterceptor("file"))
@UseGuards(JwtGuard, AdministratorGuard)
async uploadLogo(
@UploadedFile(
new ParseFilePipe({
validators: [new FileTypeValidator({ fileType: "image/png" })],
}),
)
file: Express.Multer.File,
) {
return await this.logoService.create(file.buffer);
}
} }

View File

@@ -3,6 +3,7 @@ import { EmailModule } from "src/email/email.module";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller"; import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
import { LogoService } from "./logo.service";
@Global() @Global()
@Module({ @Module({
@@ -16,6 +17,7 @@ import { ConfigService } from "./config.service";
inject: [PrismaService], inject: [PrismaService],
}, },
ConfigService, ConfigService,
LogoService,
], ],
controllers: [ConfigController], controllers: [ConfigController],
exports: [ConfigService], exports: [ConfigService],

View File

@@ -6,79 +6,114 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Config } from "@prisma/client"; import { Config } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { EventEmitter } from "events";
/**
* ConfigService extends EventEmitter to allow listening for config updates,
* now only `update` event will be emitted.
*/
@Injectable() @Injectable()
export class ConfigService { export class ConfigService extends EventEmitter {
constructor( constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[], @Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService private prisma: PrismaService,
) {} ) {
super();
}
get(key: string): any { get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter( const configVariable = this.configVariables.filter(
(variable) => variable.key == key (variable) => `${variable.category}.${variable.name}` == key,
)[0]; )[0];
if (!configVariable) throw new Error(`Config variable ${key} not found`); if (!configVariable) throw new Error(`Config variable ${key} not found`);
if (configVariable.type == "number") return parseInt(configVariable.value); const value = configVariable.value ?? configVariable.defaultValue;
if (configVariable.type == "boolean") return configVariable.value == "true";
if (configVariable.type == "number") return parseInt(value);
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text") if (configVariable.type == "string" || configVariable.type == "text")
return configVariable.value; return value;
} }
async listForAdmin() { async getByCategory(category: string) {
return await this.prisma.config.findMany({ const configVariables = await this.prisma.config.findMany({
where: { locked: { equals: false } }, orderBy: { order: "asc" },
where: { category, locked: { equals: false } },
});
return configVariables.map((variable) => {
return {
...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
};
}); });
} }
async list() { async list() {
return await this.prisma.config.findMany({ const configVariables = await this.prisma.config.findMany({
where: { secret: { equals: false } }, where: { secret: { equals: false } },
}); });
return configVariables.map((variable) => {
return {
...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
};
});
} }
async updateMany(data: { key: string; value: string | number | boolean }[]) { async updateMany(data: { key: string; value: string | number | boolean }[]) {
const response: Config[] = [];
for (const variable of data) { for (const variable of data) {
await this.update(variable.key, variable.value); response.push(await this.update(variable.key, variable.value));
} }
return data; return response;
} }
async update(key: string, value: string | number | boolean) { async update(key: string, value: string | number | boolean) {
const configVariable = await this.prisma.config.findUnique({ const configVariable = await this.prisma.config.findUnique({
where: { key }, where: {
name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
}); });
if (!configVariable || configVariable.locked) if (!configVariable || configVariable.locked)
throw new NotFoundException("Config variable not found"); throw new NotFoundException("Config variable not found");
if ( if (value === "") {
value = null;
} else if (
typeof value != configVariable.type && typeof value != configVariable.type &&
typeof value == "string" && typeof value == "string" &&
configVariable.type != "text" configVariable.type != "text"
) { ) {
throw new BadRequestException( throw new BadRequestException(
`Config variable must be of type ${configVariable.type}` `Config variable must be of type ${configVariable.type}`,
); );
} }
const updatedVariable = await this.prisma.config.update({ const updatedVariable = await this.prisma.config.update({
where: { key }, where: {
data: { value: value.toString() }, name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
data: { value: value === null ? null : value.toString() },
}); });
this.configVariables = await this.prisma.config.findMany(); this.configVariables = await this.prisma.config.findMany();
this.emit("update", key, value);
return updatedVariable; return updatedVariable;
} }
async finishSetup() {
return await this.prisma.config.update({
where: { key: "SETUP_FINISHED" },
data: { value: "true" },
});
}
} }

View File

@@ -2,21 +2,21 @@ import { Expose, plainToClass } from "class-transformer";
import { ConfigDTO } from "./config.dto"; import { ConfigDTO } from "./config.dto";
export class AdminConfigDTO extends ConfigDTO { export class AdminConfigDTO extends ConfigDTO {
@Expose()
name: string;
@Expose() @Expose()
secret: boolean; secret: boolean;
@Expose()
defaultValue: string;
@Expose() @Expose()
updatedAt: Date; updatedAt: Date;
@Expose()
description: string;
@Expose() @Expose()
obscured: boolean; obscured: boolean;
@Expose()
category: string;
from(partial: Partial<AdminConfigDTO>) { from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,
@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
fromList(partial: Partial<AdminConfigDTO>[]) { fromList(partial: Partial<AdminConfigDTO>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }) plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
); );
} }
} }

View File

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

View File

@@ -1,11 +1,10 @@
import { IsNotEmpty, IsString, ValidateIf } from "class-validator"; import { IsNotEmpty, IsString } from "class-validator";
class UpdateConfigDTO { class UpdateConfigDTO {
@IsString() @IsString()
key: string; key: string;
@IsNotEmpty() @IsNotEmpty()
@ValidateIf((dto) => dto.value !== "")
value: string | number | boolean; value: string | number | boolean;
} }

View File

@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import * as fs from "fs";
import * as sharp from "sharp";
const IMAGES_PATH = "../frontend/public/img";
@Injectable()
export class LogoService {
async create(file: Buffer) {
const resized = await sharp(file).resize(900).toBuffer();
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
this.createFavicon(file);
this.createPWAIcons(file);
}
async createFavicon(file: Buffer) {
const resized = await sharp(file).resize(16).toBuffer();
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
}
async createPWAIcons(file: Buffer) {
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
for (const size of sizes) {
const resized = await sharp(file).resize(size).toBuffer();
fs.promises.writeFile(
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
resized,
"binary",
);
}
}
}

9
backend/src/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
export const DATABASE_URL =
process.env.DATABASE_URL ||
"file:../data/pingvin-share.db?connection_limit=1";
export const CLAMAV_HOST =
process.env.CLAMAV_HOST ||
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;

View File

@@ -1,48 +1,133 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common"; import {
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as moment from "moment";
import * as nodemailer from "nodemailer"; import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() { getTransporter() {
if (!this.config.get("smtp.enabled"))
throw new InternalServerErrorException("SMTP is disabled");
return nodemailer.createTransport({ return nodemailer.createTransport({
host: this.config.get("SMTP_HOST"), host: this.config.get("smtp.host"),
port: parseInt(this.config.get("SMTP_PORT")), port: this.config.get("smtp.port"),
secure: parseInt(this.config.get("SMTP_PORT")) == 465, secure: this.config.get("smtp.port") == 465,
auth: { auth: {
user: this.config.get("SMTP_USERNAME"), user: this.config.get("smtp.username"),
pass: this.config.get("SMTP_PASSWORD"), pass: this.config.get("smtp.password"),
}, },
}); });
} }
async sendMail(recipientEmail: string, shareId: string, creator: User) { private async sendMail(email: string, subject: string, text: string) {
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS")) await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email",
)}>`,
to: email,
subject,
text,
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException("Failed to send email");
});
}
async sendMailToShareRecipients(
recipientEmail: string,
shareId: string,
creator?: User,
description?: string,
expiration?: Date,
) {
if (!this.config.get("email.enableShareEmailRecipients"))
throw new InternalServerErrorException("Email service disabled"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, recipientEmail,
to: recipientEmail, this.config.get("email.shareRecipientsSubject"),
subject: this.config.get("EMAIL_SUBJECT"), this.config
text: this.config .get("email.shareRecipientsMessage")
.get("EMAIL_MESSAGE") .replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl)
.replaceAll("{desc}", description ?? "No description")
.replaceAll(
"{expires}",
moment(expiration).unix() != 0
? moment(expiration).fromNow()
: "in: never",
),
);
}
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
await this.sendMail(
recipientEmail,
this.config.get("email.reverseShareSubject"),
this.config
.get("email.reverseShareMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{creator}", creator.username)
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl),
}); );
}
async sendResetPasswordEmail(recipientEmail: string, token: string) {
const resetPasswordUrl = `${this.config.get(
"general.appUrl",
)}/auth/resetPassword/${token}`;
await this.sendMail(
recipientEmail,
this.config.get("email.resetPasswordSubject"),
this.config
.get("email.resetPasswordMessage")
.replaceAll("\\n", "\n")
.replaceAll("{url}", resetPasswordUrl),
);
}
async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.sendMail(
recipientEmail,
this.config.get("email.inviteSubject"),
this.config
.get("email.inviteMessage")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password),
);
} }
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
await this.getTransporter().sendMail({ await this.getTransporter()
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, .sendMail({
to: recipientEmail, from: `"${this.config.get("general.appName")}" <${this.config.get(
subject: "Test email", "smtp.email",
text: "This is a test email", )}>`,
}); to: recipientEmail,
subject: "Test email",
text: "This is a test email",
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException(e.message);
});
} }
} }

View File

@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
Post, Post,
@@ -12,11 +13,10 @@ import {
import { SkipThrottle } from "@nestjs/throttler"; import { SkipThrottle } from "@nestjs/throttler";
import * as contentDisposition from "content-disposition"; import * as contentDisposition from "content-disposition";
import { Response } from "express"; import { Response } from "express";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { CreateShareGuard } from "src/share/guard/createShare.guard";
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard"; import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
@Controller("shares/:shareId/files") @Controller("shares/:shareId/files")
export class FileController { export class FileController {
@@ -24,76 +24,72 @@ export class FileController {
@Post() @Post()
@SkipThrottle() @SkipThrottle()
@UseGuards(JwtGuard, ShareOwnerGuard) @UseGuards(CreateShareGuard, ShareOwnerGuard)
async create( async create(
@Query() query: any, @Query() query: any,
@Body() body: string, @Body() body: string,
@Param("shareId") shareId: string @Param("shareId") shareId: string,
) { ) {
const { id, name, chunkIndex, totalChunks } = query; const { id, name, chunkIndex, totalChunks } = query;
const data = body.toString().split(",")[1]; // Data can be empty if the file is empty
const data = body.toString().split(",")[1] ?? "";
return await this.fileService.create( return await this.fileService.create(
data, data,
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) }, { index: parseInt(chunkIndex), total: parseInt(totalChunks) },
{ id, name }, { id, name },
shareId shareId,
); );
} }
@Get(":fileId/download")
@UseGuards(ShareSecurityGuard)
async getFileDownloadUrl(
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip/download")
@UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL(
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip") @Get("zip")
@UseGuards(FileDownloadGuard) @UseGuards(FileSecurityGuard)
async getZip( async getZip(
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string @Param("shareId") shareId: string,
) { ) {
const zip = this.fileService.getZip(shareId); const zip = this.fileService.getZip(shareId);
res.set({ res.set({
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`, "Content-Disposition": contentDisposition(`${shareId}.zip`),
}); });
return new StreamableFile(zip); return new StreamableFile(zip);
} }
@Get(":fileId") @Get(":fileId")
@UseGuards(FileDownloadGuard) @UseGuards(FileSecurityGuard)
async getFile( async getFile(
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string, @Param("shareId") shareId: string,
@Param("fileId") fileId: string @Param("fileId") fileId: string,
@Query("download") download = "true",
) { ) {
const file = await this.fileService.get(shareId, fileId); const file = await this.fileService.get(shareId, fileId);
res.set({
const headers = {
"Content-Type": file.metaData.mimeType, "Content-Type": file.metaData.mimeType,
"Content-Length": file.metaData.size, "Content-Length": file.metaData.size,
"Content-Disposition": contentDisposition(file.metaData.name), };
});
if (download === "true") {
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
}
res.set(headers);
return new StreamableFile(file.file); return new StreamableFile(file.file);
} }
@Delete(":fileId")
@SkipThrottle()
@UseGuards(ShareOwnerGuard)
async remove(
@Param("fileId") fileId: string,
@Param("shareId") shareId: string,
) {
await this.fileService.remove(shareId, fileId);
}
} }

View File

@@ -1,11 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareModule } from "src/share/share.module"; import { ShareModule } from "src/share/share.module";
import { FileController } from "./file.controller"; import { FileController } from "./file.controller";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
@Module({ @Module({
imports: [JwtModule.register({}), ShareModule], imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
controllers: [FileController], controllers: [FileController],
providers: [FileService], providers: [FileService],
exports: [FileService], exports: [FileService],

View File

@@ -11,26 +11,27 @@ import * as fs from "fs";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class FileService { export class FileService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService private config: ConfigService,
) {} ) {}
async create( async create(
data: string, data: string,
chunk: { index: number; total: number }, chunk: { index: number; total: number },
file: { id?: string; name: string }, file: { id?: string; name: string },
shareId: string shareId: string,
) { ) {
if (!file.id) file.id = crypto.randomUUID(); if (!file.id) file.id = crypto.randomUUID();
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id: shareId }, where: { id: shareId },
include: { files: true }, include: { files: true, reverseShare: true },
}); });
if (share.uploadLocked) if (share.uploadLocked)
@@ -39,7 +40,7 @@ export class FileService {
let diskFileSize: number; let diskFileSize: number;
try { try {
diskFileSize = fs.statSync( diskFileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk` `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
).size; ).size;
} catch { } catch {
diskFileSize = 0; diskFileSize = 0;
@@ -61,32 +62,35 @@ export class FileService {
// Check if share size limit is exceeded // Check if share size limit is exceeded
const fileSizeSum = share.files.reduce( const fileSizeSum = share.files.reduce(
(n, { size }) => n + parseInt(size), (n, { size }) => n + parseInt(size),
0 0,
); );
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
if ( if (
fileSizeSum + diskFileSize + buffer.byteLength > shareSizeSum > this.config.get("share.maxSize") ||
this.config.get("MAX_SHARE_SIZE") (share.reverseShare?.maxShareSize &&
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
) { ) {
throw new HttpException( throw new HttpException(
"Max share size exceeded", "Max share size exceeded",
HttpStatus.PAYLOAD_TOO_LARGE HttpStatus.PAYLOAD_TOO_LARGE,
); );
} }
fs.appendFileSync( fs.appendFileSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer buffer,
); );
const isLastChunk = chunk.index == chunk.total - 1; const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) { if (isLastChunk) {
fs.renameSync( fs.renameSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`,
); );
const fileSize = fs.statSync( const fileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`,
).size; ).size;
await this.prisma.file.create({ await this.prisma.file.create({
data: { data: {
@@ -108,9 +112,7 @@ export class FileService {
if (!fileMetaData) throw new NotFoundException("File not found"); if (!fileMetaData) throw new NotFoundException("File not found");
const file = fs.createReadStream( const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
`./data/uploads/shares/${shareId}/${fileId}`
);
return { return {
metaData: { metaData: {
@@ -122,48 +124,26 @@ export class FileService {
}; };
} }
async remove(shareId: string, fileId: string) {
const fileMetaData = await this.prisma.file.findUnique({
where: { id: fileId },
});
if (!fileMetaData) throw new NotFoundException("File not found");
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
await this.prisma.file.delete({ where: { id: fileId } });
}
async deleteAllFiles(shareId: string) { async deleteAllFiles(shareId: string) {
await fs.promises.rm(`./data/uploads/shares/${shareId}`, { await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true, recursive: true,
force: true, force: true,
}); });
} }
getZip(shareId: string) { getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
}
getFileDownloadUrl(shareId: string, fileId: string) {
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
return `${this.config.get(
"APP_URL"
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
}
generateFileDownloadToken(shareId: string, fileId: string) {
if (fileId == "zip") fileId = undefined;
return this.jwtService.sign(
{
shareId,
fileId,
},
{
expiresIn: "10min",
secret: this.config.get("JWT_SECRET"),
}
);
}
verifyFileDownloadToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId;
} catch {
return false;
}
} }
} }

View File

@@ -1,17 +0,0 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Request } from "express";
import { FileService } from "src/file/file.service";
@Injectable()
export class FileDownloadGuard implements CanActivate {
constructor(private fileService: FileService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const token = request.query.token as string;
const { shareId } = request.params;
return this.fileService.verifyFileDownloadToken(shareId, token);
}
}

View File

@@ -0,0 +1,65 @@
import {
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { ShareService } from "src/share/share.service";
@Injectable()
export class FileSecurityGuard extends ShareSecurityGuard {
constructor(
private _shareService: ShareService,
private _prisma: PrismaService,
) {
super(_shareService, _prisma);
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId",
)
? request.params.shareId
: request.params.id;
const shareToken = request.cookies[`share_${shareId}_token`];
const share = await this._prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
});
// If there is no share token the user requests a file directly
if (!shareToken) {
if (
!share ||
(moment().isAfter(share.expiration) &&
!moment(share.expiration).isSame(0))
) {
throw new NotFoundException("File not found");
}
if (share.security?.password)
throw new ForbiddenException("This share is password protected");
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded",
);
}
await this._shareService.increaseViewCount(share);
return true;
} else {
return super.canActivate(context);
}
}
}

View File

@@ -1,9 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module"; import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { JobsService } from "./jobs.service"; import { JobsService } from "./jobs.service";
@Module({ @Module({
imports: [FileModule], imports: [FileModule, ReverseShareModule],
providers: [JobsService], providers: [JobsService],
}) })
export class JobsModule {} export class JobsModule {}

View File

@@ -1,15 +1,20 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class JobsService { export class JobsService {
private readonly logger = new Logger(JobsService.name);
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private fileService: FileService private reverseShareService: ReverseShareService,
private fileService: FileService,
) {} ) {}
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -32,8 +37,50 @@ export class JobsService {
await this.fileService.deleteAllFiles(expiredShare.id); await this.fileService.deleteAllFiles(expiredShare.id);
} }
if (expiredShares.length > 0) if (expiredShares.length > 0) {
console.log(`job: deleted ${expiredShares.length} expired shares`); this.logger.log(`Deleted ${expiredShares.length} expired shares`);
}
}
@Cron("0 * * * *")
async deleteExpiredReverseShares() {
const expiredReverseShares = await this.prisma.reverseShare.findMany({
where: {
shareExpiration: { lt: new Date() },
},
});
for (const expiredReverseShare of expiredReverseShares) {
await this.reverseShareService.remove(expiredReverseShare.id);
}
if (expiredReverseShares.length > 0) {
this.logger.log(
`Deleted ${expiredReverseShares.length} expired reverse shares`,
);
}
}
@Cron("0 */6 * * *")
async deleteUnfinishedShares() {
const unfinishedShares = await this.prisma.share.findMany({
where: {
createdAt: { lt: moment().subtract(1, "day").toDate() },
uploadLocked: false,
},
});
for (const unfinishedShare of unfinishedShares) {
await this.prisma.share.delete({
where: { id: unfinishedShare.id },
});
await this.fileService.deleteAllFiles(unfinishedShare.id);
}
if (unfinishedShares.length > 0) {
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
}
} }
@Cron("0 0 * * *") @Cron("0 0 * * *")
@@ -41,42 +88,54 @@ export class JobsService {
let filesDeleted = 0; let filesDeleted = 0;
const shareDirectories = fs const shareDirectories = fs
.readdirSync("./data/uploads/shares", { withFileTypes: true }) .readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory()) .filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name); .map((dirent) => dirent.name);
for (const shareDirectory of shareDirectories) { for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs const temporaryFiles = fs
.readdirSync(`./data/uploads/shares/${shareDirectory}`) .readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk")); .filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) { for (const file of temporaryFiles) {
const stats = fs.statSync( const stats = fs.statSync(
`./data/uploads/shares/${shareDirectory}/${file}` `${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
); );
const isOlderThanOneDay = moment(stats.mtime) const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day") .add(1, "day")
.isBefore(moment()); .isBefore(moment());
if (isOlderThanOneDay) { if (isOlderThanOneDay) {
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`); fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
filesDeleted++; filesDeleted++;
} }
} }
} }
console.log(`job: deleted ${filesDeleted} temporary files`); this.logger.log(`Deleted ${filesDeleted} temporary files`);
} }
@Cron("0 * * * *") @Cron("1 * * * *")
async deleteExpiredRefreshTokens() { async deleteExpiredTokens() {
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({ const { count: refreshTokenCount } =
await this.prisma.refreshToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
const { count: loginTokenCount } = await this.prisma.loginToken.deleteMany({
where: { expiresAt: { lt: new Date() } }, where: { expiresAt: { lt: new Date() } },
}); });
if (expiredRefreshTokens.count > 0) const { count: resetPasswordTokenCount } =
console.log( await this.prisma.resetPasswordToken.deleteMany({
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens` where: { expiresAt: { lt: new Date() } },
); });
const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0) {
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
}
} }
} }

View File

@@ -1,10 +1,12 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core"; import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import * as bodyParser from "body-parser"; import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser"; import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -15,9 +17,22 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
recursive: true,
});
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
await app.listen(8080);
// Setup Swagger in development mode
if (process.env.NODE_ENV == "development") {
const config = new DocumentBuilder()
.setTitle("Pingvin Share API")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/swagger", app, document);
}
await app.listen(parseInt(process.env.PORT) || 8080);
} }
bootstrap(); 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(email: string) {
// only remove + and - from email for now (maybe not enough)
let username = email.split("@")[0].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.email),
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,136 @@
import { Injectable } from "@nestjs/common";
import fetch from "node-fetch";
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: DiscordToken = await res.json();
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,237 @@
import { Logger } from "@nestjs/common";
import fetch from "node-fetch";
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, _: unknown) => {
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: OidcToken = await res.json();
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.name ||
idTokenData.nickname ||
idTokenData.preferred_username;
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(),
};
}
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,112 @@
import { Injectable } from "@nestjs/common";
import fetch from "node-fetch";
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: GitHubToken = await res.json();
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

@@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { DATABASE_URL } from "../constants";
@Injectable() @Injectable()
export class PrismaService extends PrismaClient { export class PrismaService extends PrismaClient {
@@ -7,7 +8,7 @@ export class PrismaService extends PrismaClient {
super({ super({
datasources: { datasources: {
db: { db: {
url: "file:../data/pingvin-share.db?connection_limit=1", url: DATABASE_URL,
}, },
}, },
}); });

View File

@@ -0,0 +1,16 @@
import { IsBoolean, IsString, Max, Min } from "class-validator";
export class CreateReverseShareDTO {
@IsBoolean()
sendEmailNotification: boolean;
@IsString()
maxShareSize: string;
@IsString()
shareExpiration: string;
@Min(1)
@Max(1000)
maxUseCount: number;
}

View File

@@ -0,0 +1,21 @@
import { Expose, plainToClass } from "class-transformer";
export class ReverseShareDTO {
@Expose()
id: string;
@Expose()
maxShareSize: string;
@Expose()
shareExpiration: Date;
@Expose()
token: string;
from(partial: Partial<ReverseShareDTO>) {
return plainToClass(ReverseShareDTO, partial, {
excludeExtraneousValues: true,
});
}
}

View File

@@ -0,0 +1,29 @@
import { OmitType } from "@nestjs/swagger";
import { Expose, plainToClass, Type } from "class-transformer";
import { MyShareDTO } from "src/share/dto/myShare.dto";
import { ReverseShareDTO } from "./reverseShare.dto";
export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
"shareExpiration",
] as const) {
@Expose()
shareExpiration: Date;
@Expose()
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
shares: Omit<
MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword"
>[];
@Expose()
remainingUses: number;
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
return partial.map((part) =>
plainToClass(ReverseShareTokenWithShares, part, {
excludeExtraneousValues: true,
}),
);
}
}

View File

@@ -0,0 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ReverseShareOwnerGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const { reverseShareId } = request.params;
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { id: reverseShareId },
});
if (!reverseShare) return false;
return reverseShare.creatorId == (request.user as User).id;
}
}

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
import { ReverseShareDTO } from "./dto/reverseShare.dto";
import { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
import { ReverseShareService } from "./reverseShare.service";
@Controller("reverseShares")
export class ReverseShareController {
constructor(
private reverseShareService: ReverseShareService,
private config: ConfigService,
) {}
@Post()
@UseGuards(JwtGuard)
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
const token = await this.reverseShareService.create(body, user.id);
const link = `${this.config.get("general.appUrl")}/upload/${token}`;
return { token, link };
}
@Throttle(20, 60)
@Get(":reverseShareToken")
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
const isValid = await this.reverseShareService.isValid(reverseShareToken);
if (!isValid) throw new NotFoundException("Reverse share token not found");
return new ReverseShareDTO().from(
await this.reverseShareService.getByToken(reverseShareToken),
);
}
@Get()
@UseGuards(JwtGuard)
async getAllByUser(@GetUser() user: User) {
return new ReverseShareTokenWithShares().fromList(
await this.reverseShareService.getAllByUser(user.id),
);
}
@Delete(":reverseShareId")
@UseGuards(JwtGuard, ReverseShareOwnerGuard)
async remove(@Param("reverseShareId") id: string) {
await this.reverseShareService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module";
import { ReverseShareController } from "./reverseShare.controller";
import { ReverseShareService } from "./reverseShare.service";
@Module({
imports: [forwardRef(() => FileModule)],
controllers: [ReverseShareController],
providers: [ReverseShareService],
exports: [ReverseShareService],
})
export class ReverseShareModule {}

View File

@@ -0,0 +1,109 @@
import { BadRequestException, Injectable } from "@nestjs/common";
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()
export class ReverseShareService {
constructor(
private config: ConfigService,
private prisma: PrismaService,
private fileService: FileService,
) {}
async create(data: CreateReverseShareDTO, creatorId: string) {
// Parse date string to date
const expirationDate = moment()
.add(
data.shareExpiration.split("-")[0],
data.shareExpiration.split(
"-",
)[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.`,
);
const reverseShare = await this.prisma.reverseShare.create({
data: {
shareExpiration: expirationDate,
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification,
creatorId,
},
});
return reverseShare.token;
}
async getByToken(reverseShareToken?: string) {
if (!reverseShareToken) return null;
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { token: reverseShareToken },
});
return reverseShare;
}
async getAllByUser(userId: string) {
const reverseShares = await this.prisma.reverseShare.findMany({
where: {
creatorId: userId,
shareExpiration: { gt: new Date() },
},
orderBy: {
shareExpiration: "desc",
},
include: { shares: { include: { creator: true } } },
});
return reverseShares;
}
async isValid(reverseShareToken: string) {
const reverseShare = await this.prisma.reverseShare.findUnique({
where: { token: reverseShareToken },
});
if (!reverseShare) return false;
const isExpired = new Date() > reverseShare.shareExpiration;
const remainingUsesExceeded = reverseShare.remainingUses <= 0;
return !(isExpired || remainingUsesExceeded);
}
async remove(id: string) {
const shares = await this.prisma.share.findMany({
where: { reverseShare: { id } },
});
for (const share of shares) {
await this.prisma.share.delete({ where: { id: share.id } });
await this.fileService.deleteAllFiles(share.id);
}
await this.prisma.reverseShare.delete({ where: { id } });
}
}

View File

@@ -1,7 +1,13 @@
import { Expose, plainToClass } from "class-transformer"; import { Expose, plainToClass, Type } from "class-transformer";
import { ShareDTO } from "./share.dto"; import { ShareDTO } from "./share.dto";
import { FileDTO } from "../../file/dto/file.dto";
import { OmitType } from "@nestjs/swagger";
export class MyShareDTO extends ShareDTO { export class MyShareDTO extends OmitType(ShareDTO, [
"files",
"from",
"fromList",
] as const) {
@Expose() @Expose()
views: number; views: number;
@@ -11,13 +17,17 @@ export class MyShareDTO extends ShareDTO {
@Expose() @Expose()
recipients: string[]; recipients: string[];
@Expose()
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
files: Omit<FileDTO, "share" | "from">[];
from(partial: Partial<MyShareDTO>) { from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
} }
fromList(partial: Partial<MyShareDTO>[]) { fromList(partial: Partial<MyShareDTO>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }) plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }),
); );
} }
} }

View File

@@ -20,13 +20,16 @@ export class ShareDTO {
@Expose() @Expose()
description: string; description: string;
@Expose()
hasPassword: boolean;
from(partial: Partial<ShareDTO>) { from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
} }
fromList(partial: Partial<ShareDTO>[]) { fromList(partial: Partial<ShareDTO>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(ShareDTO, part, { excludeExtraneousValues: true }) plainToClass(ShareDTO, part, { excludeExtraneousValues: true }),
); );
} }
} }

View File

@@ -0,0 +1,29 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
@Injectable()
export class CreateShareGuard extends JwtGuard {
constructor(
configService: ConfigService,
private reverseShareService: ReverseShareService,
) {
super(configService);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (await super.canActivate(context)) return true;
const reverseShareTokenId = context.switchToHttp().getRequest()
.cookies.reverse_share_token;
if (!reverseShareTokenId) return false;
const isReverseShareTokenValid = await this.reverseShareService.isValid(
reverseShareTokenId,
);
return isReverseShareTokenValid;
}
}

View File

@@ -1,5 +1,4 @@
import { import {
CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,16 +6,23 @@ import {
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request } from "express"; import { Request } from "express";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { JwtGuard } from "../../auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class ShareOwnerGuard implements CanActivate { export class ShareOwnerGuard extends JwtGuard {
constructor(private prisma: PrismaService) {} constructor(
configService: ConfigService,
private prisma: PrismaService,
) {
super(configService);
}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest(); const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call( const shareId = Object.prototype.hasOwnProperty.call(
request.params, request.params,
"shareId" "shareId",
) )
? request.params.shareId ? request.params.shareId
: request.params.id; : request.params.id;
@@ -30,6 +36,8 @@ export class ShareOwnerGuard implements CanActivate {
if (!share.creatorId) return true; if (!share.creatorId) return true;
if (!(await super.canActivate(context))) return false;
return share.creatorId == (request.user as User).id; return share.creatorId == (request.user as User).id;
} }
} }

View File

@@ -5,7 +5,6 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express"; import { Request } from "express";
import * as moment from "moment"; import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@@ -14,21 +13,22 @@ import { ShareService } from "src/share/share.service";
@Injectable() @Injectable()
export class ShareSecurityGuard implements CanActivate { export class ShareSecurityGuard implements CanActivate {
constructor( constructor(
private reflector: Reflector,
private shareService: ShareService, private shareService: ShareService,
private prisma: PrismaService private prisma: PrismaService,
) {} ) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest(); const request: Request = context.switchToHttp().getRequest();
const shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call( const shareId = Object.prototype.hasOwnProperty.call(
request.params, request.params,
"shareId" "shareId",
) )
? request.params.shareId ? request.params.shareId
: request.params.id; : request.params.id;
const shareToken = request.cookies[`share_${shareId}_token`];
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id: shareId }, where: { id: shareId },
include: { security: true }, include: { security: true },
@@ -37,20 +37,20 @@ export class ShareSecurityGuard implements CanActivate {
if ( if (
!share || !share ||
(moment().isAfter(share.expiration) && (moment().isAfter(share.expiration) &&
moment(share.expiration).unix() !== 0) !moment(share.expiration).isSame(0))
) )
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
if (share.security?.password && !shareToken) if (share.security?.password && !shareToken)
throw new ForbiddenException( throw new ForbiddenException(
"This share is password protected", "This share is password protected",
"share_password_required" "share_password_required",
); );
if (!(await this.shareService.verifyShareToken(shareId, shareToken))) if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
throw new ForbiddenException( throw new ForbiddenException(
"Share token required", "Share token required",
"share_token_required" "share_token_required",
); );
return true; return true;

View File

@@ -1,7 +1,6 @@
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -17,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
const request: Request = context.switchToHttp().getRequest(); const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call( const shareId = Object.prototype.hasOwnProperty.call(
request.params, request.params,
"shareId" "shareId",
) )
? request.params.shareId ? request.params.shareId
: request.params.id; : request.params.id;
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
) )
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
if (share.security?.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
return true; return true;
} }
} }

View File

@@ -6,10 +6,13 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Req,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator"; import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@@ -17,6 +20,7 @@ import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto"; import { ShareDTO } from "./dto/share.dto";
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto"; import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto"; import { SharePasswordDto } from "./dto/sharePassword.dto";
import { CreateShareGuard } from "./guard/createShare.guard";
import { ShareOwnerGuard } from "./guard/shareOwner.guard"; import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard"; import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard"; import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
@@ -29,7 +33,7 @@ export class ShareController {
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getMyShares(@GetUser() user: User) { async getMyShares(@GetUser() user: User) {
return new MyShareDTO().fromList( return new MyShareDTO().fromList(
await this.shareService.getSharesByUser(user.id) await this.shareService.getSharesByUser(user.id),
); );
} }
@@ -39,6 +43,12 @@ export class ShareController {
return new ShareDTO().from(await this.shareService.get(id)); return new ShareDTO().from(await this.shareService.get(id));
} }
@Get(":id/from-owner")
@UseGuards(ShareOwnerGuard)
async getFromOwner(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.get(id));
}
@Get(":id/metaData") @Get(":id/metaData")
@UseGuards(ShareSecurityGuard) @UseGuards(ShareSecurityGuard)
async getMetaData(@Param("id") id: string) { async getMetaData(@Param("id") id: string) {
@@ -46,34 +56,61 @@ export class ShareController {
} }
@Post() @Post()
@UseGuards(JwtGuard) @UseGuards(CreateShareGuard)
async create(@Body() body: CreateShareDTO, @GetUser() user: User) { async create(
return new ShareDTO().from(await this.shareService.create(body, user)); @Body() body: CreateShareDTO,
} @Req() request: Request,
@GetUser() user: User,
@Delete(":id") ) {
@UseGuards(JwtGuard, ShareOwnerGuard) const { reverse_share_token } = request.cookies;
async remove(@Param("id") id: string) { return new ShareDTO().from(
await this.shareService.remove(id); await this.shareService.create(body, user, reverse_share_token),
);
} }
@Post(":id/complete") @Post(":id/complete")
@HttpCode(202) @HttpCode(202)
@UseGuards(JwtGuard, ShareOwnerGuard) @UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string) { async complete(@Param("id") id: string, @Req() request: Request) {
return new ShareDTO().from(await this.shareService.complete(id)); const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token),
);
} }
@Delete(":id/complete")
@UseGuards(ShareOwnerGuard)
async revertComplete(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.revertComplete(id));
}
@Delete(":id")
@UseGuards(ShareOwnerGuard)
async remove(@Param("id") id: string) {
await this.shareService.remove(id);
}
@Throttle(10, 60)
@Get("isShareIdAvailable/:id") @Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) { async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id); return this.shareService.isShareIdAvailable(id);
} }
@HttpCode(200) @HttpCode(200)
@Throttle(10, 5 * 60) @Throttle(20, 5 * 60)
@UseGuards(ShareTokenSecurity) @UseGuards(ShareTokenSecurity)
@Post(":id/token") @Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) { async getShareToken(
return this.shareService.getShareToken(id, body.password); @Param("id") id: string,
@Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto,
) {
const token = await this.shareService.getShareToken(id, body.password);
response.cookie(`share_${id}_token`, token, {
path: "/",
httpOnly: true,
});
return { token };
} }
} }

View File

@@ -1,8 +1,9 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { ClamscanModule } from "src/clamscan/clamscan.module"; import { ClamScanModule } from "src/clamscan/clamscan.module";
import { EmailModule } from "src/email/email.module"; import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module"; import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareController } from "./share.controller"; import { ShareController } from "./share.controller";
import { ShareService } from "./share.service"; import { ShareService } from "./share.service";
@@ -10,7 +11,8 @@ import { ShareService } from "./share.service";
imports: [ imports: [
JwtModule.register({}), JwtModule.register({}),
EmailModule, EmailModule,
ClamscanModule, ClamScanModule,
ReverseShareModule,
forwardRef(() => FileModule), forwardRef(() => FileModule),
], ],
controllers: [ShareController], controllers: [ShareController],

View File

@@ -15,6 +15,9 @@ import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable() @Injectable()
@@ -25,10 +28,11 @@ export class ShareService {
private emailService: EmailService, private emailService: EmailService,
private config: ConfigService, private config: ConfigService,
private jwtService: JwtService, private jwtService: JwtService,
private clasmScanService: ClamScanService private reverseShareService: ReverseShareService,
private clamScanService: ClamScanService,
) {} ) {}
async create(share: CreateShareDTO, user?: User) { async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
if (!(await this.isShareIdAvailable(share.id)).isAvailable) if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use"); throw new BadRequestException("Share id already in use");
@@ -39,30 +43,40 @@ export class ShareService {
share.security.password = await argon.hash(share.security.password); share.security.password = await argon.hash(share.security.password);
} }
// We have to add an exception for "never" (since moment won't like that)
let expirationDate: Date; let expirationDate: Date;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
// Throw error if expiration date is now // If share is created by a reverse share token override the expiration date
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) const reverseShare = await this.reverseShareService.getByToken(
throw new BadRequestException("Invalid expiration date"); reverseShareToken,
);
if (reverseShare) {
expirationDate = reverseShare.shareExpiration;
} else { } 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(`./data/uploads/shares/${share.id}`, { fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
recursive: true, recursive: true,
}); });
return await this.prisma.share.create({ const shareTuple = await this.prisma.share.create({
data: { data: {
...share, ...share,
expiration: expirationDate, expiration: expirationDate,
@@ -75,14 +89,28 @@ export class ShareService {
}, },
}, },
}); });
if (reverseShare) {
// Assign share to reverse share token
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: {
shares: {
connect: { id: shareTuple.id },
},
},
});
}
return shareTuple;
} }
async createZip(shareId: string) { async createZip(shareId: string) {
const path = `./data/uploads/shares/${shareId}`; const path = `${SHARE_DIRECTORY}/${shareId}`;
const files = await this.prisma.file.findMany({ where: { shareId } }); const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", { const archive = archiver("zip", {
zlib: { level: 9 }, zlib: { level: this.config.get("share.zipCompressionLevel") },
}); });
const writeStream = fs.createWriteStream(`${path}/archive.zip`); const writeStream = fs.createWriteStream(`${path}/archive.zip`);
@@ -96,10 +124,15 @@ export class ShareService {
await archive.finalize(); await archive.finalize();
} }
async complete(id: string) { async complete(id: string, reverseShareToken?: string) {
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id }, where: { id },
include: { files: true, recipients: true, creator: true }, include: {
files: true,
recipients: true,
creator: true,
reverseShare: { include: { creator: true } },
},
}); });
if (await this.isShareCompleted(id)) if (await this.isShareCompleted(id))
@@ -107,33 +140,60 @@ export class ShareService {
if (share.files.length == 0) if (share.files.length == 0)
throw new BadRequestException( throw new BadRequestException(
"You need at least on file in your share to complete it." "You need at least on file in your share to complete it.",
); );
// Asynchronously create a zip of all files // Asynchronously create a zip of all files
if (share.files.length > 1) if (share.files.length > 1)
this.createZip(id).then(() => this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
); );
// Send email for each recepient // Send email for each recipient
for (const recepient of share.recipients) { for (const recipient of share.recipients) {
await this.emailService.sendMail( await this.emailService.sendMailToShareRecipients(
recepient.email, recipient.email,
share.id,
share.creator,
share.description,
share.expiration,
);
}
if (
share.reverseShare &&
this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id, share.id,
share.creator
); );
} }
// Check if any file is malicious with ClamAV // Check if any file is malicious with ClamAV
this.clasmScanService.checkAndRemove(share.id); void this.clamScanService.checkAndRemove(share.id);
return await this.prisma.share.update({ if (share.reverseShare) {
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: { remainingUses: { decrement: 1 } },
});
}
return this.prisma.share.update({
where: { id }, where: { id },
data: { uploadLocked: true }, data: { uploadLocked: true },
}); });
} }
async revertComplete(id: string) {
return this.prisma.share.update({
where: { id },
data: { uploadLocked: false, isZipReady: false },
});
}
async getSharesByUser(userId: string) { async getSharesByUser(userId: string) {
const shares = await this.prisma.share.findMany({ const shares = await this.prisma.share.findMany({
where: { where: {
@@ -148,25 +208,24 @@ export class ShareService {
orderBy: { orderBy: {
expiration: "desc", expiration: "desc",
}, },
include: { recipients: true }, include: { recipients: true, files: true },
}); });
const sharesWithEmailRecipients = shares.map((share) => { return shares.map((share) => {
return { return {
...share, ...share,
recipients: share.recipients.map((recipients) => recipients.email), recipients: share.recipients.map((recipients) => recipients.email),
}; };
}); });
return sharesWithEmailRecipients;
} }
async get(id: string) { async get(id: string): Promise<any> {
const share = await this.prisma.share.findUnique({ const share = await this.prisma.share.findUnique({
where: { id }, where: { id },
include: { include: {
files: true, files: true,
creator: true, creator: true,
security: true,
}, },
}); });
@@ -175,8 +234,10 @@ export class ShareService {
if (!share || !share.uploadLocked) if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
return {
return share as any; ...share,
hasPassword: !!share.security?.password,
};
} }
async getMetaData(id: string) { async getMetaData(id: string) {
@@ -230,12 +291,20 @@ export class ShareService {
if ( if (
share?.security?.password && share?.security?.password &&
!(await argon.verify(share.security.password, password)) !(await argon.verify(share.security.password, password))
) ) {
throw new ForbiddenException("Wrong password"); throw new ForbiddenException("Wrong password", "wrong_password");
}
if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded",
);
}
const token = await this.generateShareToken(shareId); const token = await this.generateShareToken(shareId);
await this.increaseViewCount(share); await this.increaseViewCount(share);
return { token }; return token;
} }
async generateShareToken(shareId: string) { async generateShareToken(shareId: string) {
@@ -248,8 +317,8 @@ export class ShareService {
}, },
{ {
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
} },
); );
} }
@@ -260,7 +329,7 @@ export class ShareService {
try { try {
const claims = this.jwtService.verify(token, { const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
// Ignore expiration if expiration is 0 // Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0), ignoreExpiration: moment(expiration).isSame(0),
}); });

View File

@@ -1,12 +1,15 @@
import { Expose, plainToClass } from "class-transformer"; import { plainToClass } from "class-transformer";
import { Allow } from "class-validator"; import { Allow, IsOptional, MinLength } from "class-validator";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class CreateUserDTO extends UserDTO { export class CreateUserDTO extends UserDTO {
@Expose()
@Allow() @Allow()
isAdmin: boolean; isAdmin: boolean;
@MinLength(8)
@IsOptional()
password: string;
from(partial: Partial<CreateUserDTO>) { from(partial: Partial<CreateUserDTO>) {
return plainToClass(CreateUserDTO, partial, { return plainToClass(CreateUserDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/mapped-types"; import { PickType } from "@nestjs/swagger";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {} export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}

View File

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

View File

@@ -1,4 +1,4 @@
import { PartialType } from "@nestjs/mapped-types"; import { PartialType } from "@nestjs/swagger";
import { CreateUserDTO } from "./createUser.dto"; import { CreateUserDTO } from "./createUser.dto";
export class UpdateUserDto extends PartialType(CreateUserDTO) {} export class UpdateUserDto extends PartialType(CreateUserDTO) {}

View File

@@ -16,6 +16,9 @@ export class UserDTO {
@IsEmail() @IsEmail()
email: string; email: string;
@Expose()
hasPassword: boolean;
@MinLength(8) @MinLength(8)
password: string; password: string;
@@ -31,7 +34,7 @@ export class UserDTO {
fromList(partial: Partial<UserDTO>[]) { fromList(partial: Partial<UserDTO>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(UserDTO, part, { excludeExtraneousValues: true }) plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
); );
} }
} }

View File

@@ -6,9 +6,11 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator"; import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
@@ -26,21 +28,32 @@ export class UserController {
@Get("me") @Get("me")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getCurrentUser(@GetUser() user: User) { async getCurrentUser(@GetUser() user: User) {
return new UserDTO().from(user); const userDTO = new UserDTO().from(user);
userDTO.hasPassword = !!user.password;
return userDTO;
} }
@Patch("me") @Patch("me")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async updateCurrentUser( async updateCurrentUser(
@GetUser() user: User, @GetUser() user: User,
@Body() data: UpdateOwnUserDTO @Body() data: UpdateOwnUserDTO,
) { ) {
return new UserDTO().from(await this.userService.update(user.id, data)); return new UserDTO().from(await this.userService.update(user.id, data));
} }
@Delete("me") @Delete("me")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async deleteCurrentUser(@GetUser() user: User) { async deleteCurrentUser(
@GetUser() user: User,
@Res({ passthrough: true }) response: Response,
) {
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {
path: "/api/auth/token",
httpOnly: true,
maxAge: -1,
});
return new UserDTO().from(await this.userService.delete(user.id)); return new UserDTO().from(await this.userService.delete(user.id));
} }

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