Compare commits

...

245 Commits

Author SHA1 Message Date
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
Elias Schneider
e09213a295 release: 0.7.0 2023-01-13 10:59:52 +01:00
Elias Schneider
fc116d65c0 chore: dump packages 2023-01-13 10:31:22 +01:00
Elias Schneider
76088cc76a feat: add ClamAV to scan for malicious files 2023-01-13 10:16:35 +01:00
Elias Schneider
16b697053a ci/cd: don't stale feature issues 2023-01-12 13:47:09 +01:00
Elias Schneider
349bf475cc fix: invalid github release link on admin page 2023-01-11 22:32:37 +01:00
Elias Schneider
fccc4cbc02 release: 0.6.1 2023-01-11 13:08:09 +01:00
Elias Schneider
f1b44f87fa fix: shareUrl uses wrong origin 2023-01-11 13:06:38 +01:00
Elias Schneider
02e41e2437 feat: delete all sessions if password was changed 2023-01-10 13:32:37 +01:00
Elias Schneider
74e8956106 fix: update password doesn't work 2023-01-10 12:29:38 +01:00
Elias Schneider
dc9ec429c6 release: 0.6.0 2023-01-09 12:14:41 +01:00
Elias Schneider
653d72bcb9 feat: chunk uploads (#76)
* add first concept

* finished first concept

* allow 3 uploads at same time

* retry if chunk failed

* updated clean temporary files job

* fix throttling for chunk uploads

* update tests

* remove multer

* migrate from `MAX_FILE_SIZE` to `MAX_SHARE_SIZE`

* improve error handling if file failed to upload

* fix promise limit

* improve file progress
2023-01-09 11:43:48 +01:00
Elias Schneider
a5bef5d4a4 fix: refresh token expires after 1 day instead of 3 months 2023-01-07 12:16:03 +01:00
Elias Schneider
c8ad2225e3 fix: access token refreshes even it is still valid 2023-01-06 16:07:07 +01:00
Elias Schneider
72c8081e7c fix: error message typo 2023-01-06 09:21:46 +01:00
Elias Schneider
f2d4895e50 fix: migration for v0.5.1 2023-01-05 08:34:31 +01:00
Elias Schneider
54f591cd60 release: 0.5.1 2023-01-04 16:02:54 +01:00
Elias Schneider
f836a0a3cd chore: add db migration 2023-01-04 15:58:15 +01:00
Elias Schneider
11174656e4 fix: email configuration updated without restart 2023-01-04 15:30:49 +01:00
Elias Schneider
faea1abcc4 feat: use cookies for authentication 2023-01-04 11:54:28 +01:00
Elias Schneider
71658ad39d feat: show version and show button if new release is available on admin page 2022-12-30 19:23:17 +01:00
Elias Schneider
167f0f8c7a chore: improve release scripts 2022-12-30 18:59:05 +01:00
Elias Schneider
85551dc3d3 release: 0.5.0 2022-12-30 14:41:23 +01:00
Elias Schneider
5bc4f902f6 feat: improve config UI (#69)
* add first concept

* completed configuration ui update

* add button for testing email configuration

* improve mobile layout

* add migration

* run formatter

* delete unnecessary modal

* remove unused comment
2022-12-30 14:40:23 +01:00
Elias Schneider
e5b50f855c fix: refresh token gets deleted on session end 2022-12-26 12:57:54 +01:00
Elias Schneider
b73144295b refactor: extract totp operations in seperate service 2022-12-26 12:43:36 +01:00
Elias Schneider
ef21bac59b feat: manually switch color scheme 2022-12-24 23:58:31 +01:00
Elias Schneider
cabaee588b feat: custom mail subject 2022-12-23 10:57:09 +01:00
Elias Schneider
aac363bb37 release: 0.4.0 2022-12-21 18:25:00 +01:00
Elias Schneider
af71317ec4 Merge remote-tracking branch 'origin/main' into main 2022-12-21 18:01:06 +01:00
Steve
16480f6e95 feat: TOTP (two-factor) Authentication (#55)
* Working on some initial prototype stuff for TOTP

* Fixed a bug that prevented the change password menu from working

* Enable/disable totp working

* Added the new login procedure including TOTP! :)

* misc: Changed bad description for the TOTP_SECRET env var

* I forgot to include the migration for the new prisma stuff

* fix: refresh user context instead refreshing the page

* refactor: simplify totp error handling

* Removed U2F tab + format schema

* fix: tokens not saved in cookies

* refactor: deleted commented out code

* refactor: move password text to input description

* refactor: remove tabler icon package

Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Elias Schneider <58886915+stonith404@users.noreply.github.com>
2022-12-21 17:58:37 +01:00
Elias Schneider
1a034a1966 refector: remove unnecessary content type header 2022-12-15 21:50:22 +01:00
Elias Schneider
0616a68bd2 feat: custom email message 2022-12-15 21:44:04 +01:00
Elias Schneider
bfb47ba6e8 release: 0.3.6 2022-12-13 18:45:52 +01:00
Elias Schneider
c1d87a1c29 test: improve tests for new feature 2022-12-13 18:44:17 +01:00
Elias Schneider
4c7e161217 chore: create prisma migration 2022-12-13 18:39:13 +01:00
Elias Schneider
844c47e129 fix: rerange accordion items 2022-12-13 09:57:48 +01:00
Elias Schneider
9b0c08d0cd fix: remove dot in email link 2022-12-13 09:06:18 +01:00
Elias Schneider
37fda220e9 Merge branch 'main' of https://github.com/stonith404/pingvin-share 2022-12-12 22:38:40 +01:00
Elias Schneider
3b7f5ddc52 Create close_inactive_issues.yml 2022-12-12 14:34:36 +01:00
Elias Schneider
8728fa5207 feat: add description field to share 2022-12-12 11:54:13 +01:00
Elias Schneider
c265129dcc Create SECURITY.md 2022-12-12 11:11:28 +01:00
Elias Schneider
78dd4a7e2a chore: add issue templates 2022-12-12 11:00:10 +01:00
Elias Schneider
3cad4dd487 docs: add synology nas installation by Marius 2022-12-11 12:38:58 +01:00
Elias Schneider
d1d3462056 release: 0.3.5 2022-12-11 12:23:46 +01:00
Elias Schneider
5b01108777 fix: zip doesn't contain file extension 2022-12-11 12:22:01 +01:00
Elias Schneider
3d1d4d0fc7 fix: only create zip if more than one file is in the share 2022-12-11 12:19:42 +01:00
Elias Schneider
7c0d62a429 Update FUNDING.yml 2022-12-10 23:26:57 +01:00
Elias Schneider
d010a8a2d3 feat: upload 3 files at same time 2022-12-10 23:16:10 +01:00
Elias Schneider
9798e26872 fix: setup wizard table doesn't take full width 2022-12-10 18:45:53 +01:00
Elias Schneider
0c10dc674f Merge pull request #37 from Neyxo/improvement-30-docker-image
Improved docker image (size & speed)
2022-12-10 18:33:45 +01:00
Elias Schneider
084e911eed fix: remove unnecessary port expose 2022-12-10 18:32:14 +01:00
Elias Schneider
797f8938ca fix: use node slim to fix arm builds 2022-12-10 18:31:39 +01:00
Elias Schneider
05cbb7b27e fix: jobs never get executed 2022-12-10 17:16:49 +01:00
Elias Schneider
905bab9c86 release: 0.3.4 2022-12-10 15:46:53 +01:00
Jean-Michel Carrel
8e38c5fed7 Improved docker image (size & speed) 2022-12-10 15:34:01 +01:00
Elias Schneider
7e877ce9f4 fix: show alternative to copy button if site is not using https 2022-12-10 13:16:23 +01:00
Elias Schneider
b1bfb09dfd fix: tables on mobile 2022-12-09 14:37:09 +01:00
Elias Schneider
c8a4521677 fix: sign up page available when registration is disabled 2022-12-09 12:05:43 +01:00
Elias Schneider
3c74cc14df release: 0.3.3 2022-12-08 23:22:59 +01:00
Elias Schneider
a165f8ec4d refactor: remove console log 2022-12-08 23:22:15 +01:00
Elias Schneider
d6a88f2a22 performance: reduce docker image size 2022-12-08 23:21:31 +01:00
Elias Schneider
b8172efd59 fix: allow empty strings in config variable 2022-12-08 23:21:16 +01:00
Elias Schneider
cbe37c6798 fix: obscured text length 2022-12-08 23:12:25 +01:00
Elias Schneider
a545c44426 fix: improve admin dashboard color and layout 2022-12-08 22:43:14 +01:00
Elias Schneider
08a2f60f72 chore: add migration for v0.3.3 2022-12-08 21:58:58 +01:00
Elias Schneider
907e56af0f fix: space character in email 2022-12-08 20:04:56 +01:00
Elias Schneider
888a0c5faf feat: add support for different email and user 2022-12-08 20:00:04 +01:00
Elias Schneider
bfb0d151ea fix: obscure critical config variables 2022-12-08 19:14:06 +01:00
Elias Schneider
1f63f22591 docs: add review to README 2022-12-08 17:30:12 +01:00
Elias Schneider
a2d5e0f72c test: fix system tests not await backend start 2022-12-07 13:44:02 +01:00
Elias Schneider
c0d0f6fa90 release: 0.3.2 2022-12-07 12:41:14 +01:00
Elias Schneider
4a016ed57d fix: unauthenticated dialog not shown 2022-12-06 11:05:04 +01:00
Elias Schneider
5ea63fb60b fix: use session storage for share token 2022-12-06 10:54:17 +01:00
Elias Schneider
57cb683c64 fix: make share password optional 2022-12-05 23:58:18 +01:00
Elias Schneider
783b8c2e91 release: 0.3.1 2022-12-05 22:09:53 +01:00
Elias Schneider
75f57a4e57 fix: dropzone rejection on chrome 2022-12-05 22:09:41 +01:00
Elias Schneider
eb142b75f7 ci/cd: remove .env step 2022-12-05 18:23:19 +01:00
Elias Schneider
90a3c69954 release: 0.3.0 2022-12-05 18:18:41 +01:00
Elias Schneider
50887b000d Merge pull request #27 from stonith404/feat/administrator-page
Feat/administrator page
2022-12-05 18:12:57 +01:00
Elias Schneider
e2527de976 refactor: remove type email 2022-12-05 18:09:18 +01:00
Elias Schneider
b5c7b04fcb chore: upgrade dependencies 2022-12-05 17:27:19 +01:00
Elias Schneider
38f493ac5a refactor: run formatter 2022-12-05 16:54:15 +01:00
Elias Schneider
0499548dd3 refactor: convert config variables to upper case 2022-12-05 16:53:52 +01:00
Elias Schneider
d4a0f1a4f1 fix: unable to update user privileges 2022-12-05 16:17:41 +01:00
Elias Schneider
c795b988df fix: share password validation 2022-12-05 16:04:10 +01:00
Elias Schneider
7a3967fd6f feat: add user management 2022-12-05 15:53:24 +01:00
Elias Schneider
31b3f6cb2f feat: add user operations to backend 2022-12-05 10:02:19 +01:00
Elias Schneider
e9526fc039 fix: database migration by adding a username 2022-12-02 23:00:24 +01:00
Elias Schneider
6b0b979414 docs: updated README for new version 2022-12-02 20:33:17 +01:00
Elias Schneider
176196bc35 refactor: remove providers and controllers from app module 2022-12-02 20:17:28 +01:00
Elias Schneider
e958a83b87 fix: docker build 2022-12-02 15:10:49 +01:00
Elias Schneider
63368557c1 refactor: remove .env variables 2022-12-02 14:45:42 +01:00
Elias Schneider
1dbfe0bbc9 fix: convert async function to sync function 2022-12-02 14:43:52 +01:00
Elias Schneider
b649d8bf8e feat: add job that deleted temporary files 2022-12-01 23:21:12 +01:00
Elias Schneider
b579b8f330 feat: add setup wizard 2022-12-01 23:07:49 +01:00
Elias Schneider
493705e4ef feat: add add new config strategy to frontend 2022-11-28 17:50:36 +01:00
Elias Schneider
1b5e53ff7e feat: add new config strategy to backend 2022-11-28 15:04:32 +01:00
Elias Schneider
13f98cc32c feat: add administrator guard 2022-11-14 17:03:45 +01:00
Elias Schneider
29b4a825d1 test: add email recepients to request body 2022-11-13 23:38:04 +01:00
Elias Schneider
53c7457697 release: 0.2.0 2022-11-13 23:27:51 +01:00
Elias Schneider
1abc0f7ef3 chore: migrate database for release 2022-11-13 23:24:59 +01:00
Elias Schneider
2c3760e064 chore: add smtp environment variables to docker compose 2022-11-13 23:08:51 +01:00
Elias Schneider
32eaee4236 fix: email sending when not signed in 2022-11-13 23:08:25 +01:00
Elias Schneider
99492c2ecc docs: add SMTP variables to readme 2022-11-13 22:39:04 +01:00
Elias Schneider
34db3ae2a9 fix: hide and disallow email recipients if disabled 2022-11-11 19:03:08 +01:00
Elias Schneider
32ad43ae27 feat: add email recepients functionality 2022-11-11 15:12:16 +01:00
Elias Schneider
0efd2d8bf9 fix: add public userDTO to prevent confusion 2022-11-10 13:50:52 +01:00
Elias Schneider
43299522ee chore: upgrade to Next.js 13 2022-10-31 11:20:54 +01:00
Elias Schneider
880ad85a1e release: 0.1.1 2022-10-31 10:34:55 +01:00
Elias Schneider
e40cc0f48b fix: only log jobs if they actually did something 2022-10-31 10:33:27 +01:00
Elias Schneider
46d667776f chore: add dev deployment script 2022-10-31 10:28:46 +01:00
Elias Schneider
99de4e57e1 fix: share finishes before all files are uploaded 2022-10-31 10:28:29 +01:00
Elias Schneider
00d0df731b docs: improve upgrade instruction 2022-10-30 13:24:50 +01:00
Elias Schneider
599d8caa31 fix: add ALLOW_UNAUTHENTICATED_SHARES to docker compose file 2022-10-30 00:25:49 +02:00
Elias Schneider
aff58da3a2 release: 0.1.0 2022-10-29 23:12:32 +02:00
Elias Schneider
63e0af3484 refactor: run formatter 2022-10-29 23:12:29 +02:00
Elias Schneider
c2ddce6203 fix: infinite loading when file size is small 2022-10-29 23:09:28 +02:00
Elias Schneider
c6e1f07f51 fix: only show not signed in warning if not signed in 2022-10-29 22:55:46 +02:00
Elias Schneider
c8021a42b7 fix: visitor count doesn't get incremented 2022-10-29 22:49:30 +02:00
Elias Schneider
d0901d497b ix: jwt guard when unauthenticated shares are allowed 2022-10-29 22:48:00 +02:00
Elias Schneider
ffdecbd32e Revert "fix: jwt guard when unauthenticated shares are allowed"
This reverts commit c2b87aba5c.
2022-10-29 22:47:23 +02:00
Elias Schneider
712cfe625a feat: add rate limiting 2022-10-24 12:11:10 +02:00
Elias Schneider
c2b87aba5c fix: jwt guard when unauthenticated shares are allowed 2022-10-24 09:25:45 +02:00
Elias Schneider
e4019612f8 Merge pull request #18 from stonith404/feat/allow-unauthenticated-shares
feat: allow unauthenticated shares
2022-10-24 09:15:28 +02:00
Elias Schneider
af6e2b61c0 docs: add ALLOW_UNAUTHENTICATED_SHARES to README 2022-10-24 09:12:12 +02:00
Elias Schneider
7237928844 chore: create database migration 2022-10-18 14:32:40 +02:00
Elias Schneider
38986c971a refactor: run formatter 2022-10-18 14:30:41 +02:00
Elias Schneider
84d29dff68 feat: allow unauthenticated uploads 2022-10-18 14:27:14 +02:00
Elias Schneider
41c3bafbd7 chore: upgrade packages to fix vulnerabilities 2022-10-18 09:43:13 +02:00
Elias Schneider
c52a4d5e3a feat(frontend): remove footer 2022-10-18 09:31:13 +02:00
Elias Schneider
239b18cdae fix: opt out of static site generation to enable publicRuntimeConfig 2022-10-18 09:17:57 +02:00
Elias Schneider
a8a56321dd chore: change changelog generator to conventionalcommits 2022-10-18 09:12:11 +02:00
234 changed files with 18314 additions and 7020 deletions

View File

@@ -1,10 +0,0 @@
# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables
# GENERAL
APP_URL=http://localhost:3000
SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=1000000000
# SECURITY
JWT_SECRET=long-random-string

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# These are supported funding model platforms
github: stonith404

45
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: "🐛 Bug Report"
description: "Submit a bug report to help us improve"
title: "🐛 Bug Report: "
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out our bug report form 🙏
- type: textarea
id: steps-to-reproduce
validations:
required: true
attributes:
label: "👟 Reproduction steps"
description: "How do you trigger this bug? Please walk us through it step by step."
placeholder: "When I ..."
- type: textarea
id: expected-behavior
validations:
required: true
attributes:
label: "👍 Expected behavior"
description: "What did you think would happen?"
placeholder: "It should ..."
- type: textarea
id: actual-behavior
validations:
required: true
attributes:
label: "👎 Actual Behavior"
description: "What did actually happen? Add screenshots, if applicable."
placeholder: "It actually ..."
- type: input
id: operating-system
attributes:
label: "🌐 Browser"
description: "Which browser do you use?"
placeholder: "Firefox"
validations:
required: true
- type: markdown
attributes:
value: |
Before submitting, please check if the issues hasn't been raised before.

29
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
labels: [feature]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out our feature request form 🙏
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: "🔖 Feature description"
description: "A clear and concise description of what the feature is."
placeholder: "You should add ..."
- type: textarea
id: pitch
validations:
required: true
attributes:
label: "🎤 Pitch"
description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable."
placeholder: "In my use-case, ..."
- type: markdown
attributes:
value: |
Before submitting, please check if the issues hasn't been raised before.

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,13 +13,10 @@ jobs:
runs-on: ubuntu-latest
container: node:18
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install Dependencies
working-directory: ./backend
run: npm install
- name: Create .env file
working-directory: ./backend
run: mv .env.example .env
- name: Run Server and Test with Newman
working-directory: ./backend
run: npm run test:system

View File

@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- 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: Build the image

View File

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

3
.gitignore vendored
View File

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

1
.prettierignore Normal file
View File

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

View File

@@ -1,4 +1,421 @@
## 0.0.1 (2022-10-17)
## [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)
### Features
* add ClamAV to scan for malicious files ([76088cc](https://github.com/stonith404/pingvin-share/commit/76088cc76aedae709f06deaee2244efcf6a22bed))
### Bug Fixes
* invalid github release link on admin page ([349bf47](https://github.com/stonith404/pingvin-share/commit/349bf475cc7fc1141dbd2a9bd2f63153c4d5b41b))
### [0.6.1](https://github.com/stonith404/pingvin-share/compare/v0.6.0...v0.6.1) (2023-01-11)
### Features
* delete all sessions if password was changed ([02e41e2](https://github.com/stonith404/pingvin-share/commit/02e41e243768de34de1bdc8833e83f60db530e55))
### Bug Fixes
* shareUrl uses wrong origin ([f1b44f8](https://github.com/stonith404/pingvin-share/commit/f1b44f87fa64d3b21ca92c9068cb352d0ad51bc0))
* update password doesn't work ([74e8956](https://github.com/stonith404/pingvin-share/commit/74e895610642552c98c0015d0f8347735aaed457))
## [0.6.0](https://github.com/stonith404/pingvin-share/compare/v0.5.1...v0.6.0) (2023-01-09)
### Features
* chunk uploads ([#76](https://github.com/stonith404/pingvin-share/issues/76)) ([653d72b](https://github.com/stonith404/pingvin-share/commit/653d72bcb958268e2f23efae94cccb72faa745af))
### Bug Fixes
* access token refreshes even it is still valid ([c8ad222](https://github.com/stonith404/pingvin-share/commit/c8ad2225e3c9ca79fea494d538b67797fbc7f6ae))
* error message typo ([72c8081](https://github.com/stonith404/pingvin-share/commit/72c8081e7c135ab1f600ed7e3d7a0bf03dabde34))
* migration for v0.5.1 ([f2d4895](https://github.com/stonith404/pingvin-share/commit/f2d4895e50d3da82cef68858752fb7f6293e7a20))
* refresh token expires after 1 day instead of 3 months ([a5bef5d](https://github.com/stonith404/pingvin-share/commit/a5bef5d4a4ae75447ca1f65259c5541edfc87dd8))
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
### Features
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
### Bug Fixes
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
### Features
* custom mail subject ([cabaee5](https://github.com/stonith404/pingvin-share/commit/cabaee588b50877872d210c870bfb9c95b541921))
* improve config UI ([#69](https://github.com/stonith404/pingvin-share/issues/69)) ([5bc4f90](https://github.com/stonith404/pingvin-share/commit/5bc4f902f6218a09423491404806a4b7fb865c98))
* manually switch color scheme ([ef21bac](https://github.com/stonith404/pingvin-share/commit/ef21bac59b11dc68649ab3b195dcb89d2b192e7b))
### Bug Fixes
* refresh token gets deleted on session end ([e5b50f8](https://github.com/stonith404/pingvin-share/commit/e5b50f855c02aa4b5c9ee873dd5a7ab25759972d))
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
### Features
* custom email message ([0616a68](https://github.com/stonith404/pingvin-share/commit/0616a68bd2e0c9cb559ebdf294e353dd3f69c9a5))
* TOTP (two-factor) Authentication ([#55](https://github.com/stonith404/pingvin-share/issues/55)) ([16480f6](https://github.com/stonith404/pingvin-share/commit/16480f6e9572011fadeb981a388b92cb646fa6d9))
### [0.3.6](https://github.com/stonith404/pingvin-share/compare/v0.3.5...v0.3.6) (2022-12-13)
### Features
* add description field to share ([8728fa5](https://github.com/stonith404/pingvin-share/commit/8728fa5207524e9aee26d68eafe1b6fff367d749))
### Bug Fixes
* remove dot in email link ([9b0c08d](https://github.com/stonith404/pingvin-share/commit/9b0c08d0cdeeeef217ccba57f593fea9d8858371))
* rerange accordion items ([844c47e](https://github.com/stonith404/pingvin-share/commit/844c47e1290fb0f7dedb41a18be59ed5ab83dabc))
### [0.3.5](https://github.com/stonith404/pingvin-share/compare/v0.3.4...v0.3.5) (2022-12-11)
### Features
* upload 3 files at same time ([d010a8a](https://github.com/stonith404/pingvin-share/commit/d010a8a2d366708b1bb5088e9c1e9f9378d3e023))
### Bug Fixes
* jobs never get executed ([05cbb7b](https://github.com/stonith404/pingvin-share/commit/05cbb7b27ef98a3a80dd9edc318f1dcc9a8bd442))
* only create zip if more than one file is in the share ([3d1d4d0](https://github.com/stonith404/pingvin-share/commit/3d1d4d0fc7c0351724387c3721280c334ae94d98))
* remove unnecessary port expose ([084e911](https://github.com/stonith404/pingvin-share/commit/084e911eed95eb22fea0bf185803ba32c3eda1a9))
* setup wizard table doesn't take full width ([9798e26](https://github.com/stonith404/pingvin-share/commit/9798e26872064edc1049138cf73479b1354a43ed))
* use node slim to fix arm builds ([797f893](https://github.com/stonith404/pingvin-share/commit/797f8938cac9cc3bb788f632d97eba5c49fe98a5))
* zip doesn't contain file extension ([5b01108](https://github.com/stonith404/pingvin-share/commit/5b0110877745f1fcde4952737a93c07ef4a2a92d))
### [0.3.4](https://github.com/stonith404/pingvin-share/compare/v0.3.3...v0.3.4) (2022-12-10)
### Bug Fixes
* show alternative to copy button if site is not using https ([7e877ce](https://github.com/stonith404/pingvin-share/commit/7e877ce9f4b82d61c9b238e17def9f4c29e7aeb8))
* sign up page available when registration is disabled ([c8a4521](https://github.com/stonith404/pingvin-share/commit/c8a4521677280d6aba89d293a1fe0c38adf9f92c))
* tables on mobile ([b1bfb09](https://github.com/stonith404/pingvin-share/commit/b1bfb09dfd5c90cc18847470a9ce1ce8397c1476))
### [0.3.3](https://github.com/stonith404/pingvin-share/compare/v0.3.2...v0.3.3) (2022-12-08)
### Features
* add support for different email and user ([888a0c5](https://github.com/stonith404/pingvin-share/commit/888a0c5fafc51b6872ed71e37d4b40c9bf6a07f1))
### Bug Fixes
* allow empty strings in config variable ([b8172ef](https://github.com/stonith404/pingvin-share/commit/b8172efd59fb3271ab9b818b13a7003342b2cebd))
* improve admin dashboard color and layout ([a545c44](https://github.com/stonith404/pingvin-share/commit/a545c444261c90105dcb165ebcf4b26634e729ca))
* obscure critical config variables ([bfb0d15](https://github.com/stonith404/pingvin-share/commit/bfb0d151ea2ba125e536a16b1873e143a67e9f64))
* obscured text length ([cbe37c6](https://github.com/stonith404/pingvin-share/commit/cbe37c679853ecef1522ed213e4cac5defd5b45a))
* space character in email ([907e56a](https://github.com/stonith404/pingvin-share/commit/907e56af0faccdbc8d7f5ab3418a4ad71ff849f5))
### [0.3.2](https://github.com/stonith404/pingvin-share/compare/v0.3.1...v0.3.2) (2022-12-07)
### Bug Fixes
* make share password optional ([57cb683](https://github.com/stonith404/pingvin-share/commit/57cb683c64eaedec2697ea6863948bd2ae68dd75))
* unauthenticated dialog not shown ([4a016ed](https://github.com/stonith404/pingvin-share/commit/4a016ed57db526ee900c567f7b7f0991f948c631))
* use session storage for share token ([5ea63fb](https://github.com/stonith404/pingvin-share/commit/5ea63fb60be0c508c38ba228cc8ac6dd7b403aac))
### [0.3.1](https://github.com/stonith404/pingvin-share/compare/v0.3.0...v0.3.1) (2022-12-05)
### Bug Fixes
* dropzone rejection on chrome ([75f57a4](https://github.com/stonith404/pingvin-share/commit/75f57a4e57fb13cc62e87428e8302b453ea6b44b))
## [0.3.0](https://github.com/stonith404/pingvin-share/compare/v0.2.0...v0.3.0) (2022-12-05)
### Features
* add add new config strategy to frontend ([493705e](https://github.com/stonith404/pingvin-share/commit/493705e4ef21cb638620b0037b9ff2cec8046c95))
* add administrator guard ([13f98cc](https://github.com/stonith404/pingvin-share/commit/13f98cc32c804c786c71b10dc4cf029d7795be76))
* add job that deleted temporary files ([b649d8b](https://github.com/stonith404/pingvin-share/commit/b649d8bf8e849aff3f350e3c5fd0151a063b9706))
* add new config strategy to backend ([1b5e53f](https://github.com/stonith404/pingvin-share/commit/1b5e53ff7ee00228eda6dc5c62d5cd8c3752b03b))
* add setup wizard ([b579b8f](https://github.com/stonith404/pingvin-share/commit/b579b8f3309e2d7070e6a82c5da76ab8029bee11))
* add user management ([7a3967f](https://github.com/stonith404/pingvin-share/commit/7a3967fd6f76a03461d05e962e82fe5130528ca5))
* add user operations to backend ([31b3f6c](https://github.com/stonith404/pingvin-share/commit/31b3f6cb2fc662623df92cdbaf803f1b98a696ae))
### Bug Fixes
* convert async function to sync function ([1dbfe0b](https://github.com/stonith404/pingvin-share/commit/1dbfe0bbc9821bbee02220484c87cf9fe12fd033))
* database migration by adding a username ([e9526fc](https://github.com/stonith404/pingvin-share/commit/e9526fc0390cc8ba70c824370041ea9aaf6f9ef9))
* docker build ([e958a83](https://github.com/stonith404/pingvin-share/commit/e958a83b87a452e42fb38c12d4b11d71b2323c2d))
* share password validation ([c795b98](https://github.com/stonith404/pingvin-share/commit/c795b988df437c85efb91e0f6f8ec782f38dbe3d))
* unable to update user privileges ([d4a0f1a](https://github.com/stonith404/pingvin-share/commit/d4a0f1a4f16b7980fb244a4e582ceeb9bfaff877))
## [0.2.0](https://github.com/stonith404/pingvin-share/compare/v0.1.1...v0.2.0) (2022-11-13)
### Features
* add email recepients functionality ([32ad43a](https://github.com/stonith404/pingvin-share/commit/32ad43ae27a29b946bfba0040cac7eb158c84553))
### Bug Fixes
* add public userDTO to prevent confusion ([0efd2d8](https://github.com/stonith404/pingvin-share/commit/0efd2d8bf96506cf7d7dc2dc3164a8d59009cec7))
* email sending when not signed in ([32eaee4](https://github.com/stonith404/pingvin-share/commit/32eaee42363250defa92913c738a2702ba3e2693))
* hide and disallow email recipients if disabled ([34db3ae](https://github.com/stonith404/pingvin-share/commit/34db3ae2a997498edaa70404807d0e770dad6edb))
### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)
### Bug Fixes
* add `ALLOW_UNAUTHENTICATED_SHARES` to docker compose file ([599d8ca](https://github.com/stonith404/pingvin-share/commit/599d8caa31dc018c14c959d6602ac652eaef5da2))
* only log jobs if they actually did something ([e40cc0f](https://github.com/stonith404/pingvin-share/commit/e40cc0f48beec09e18738de1b445cabd9daab09b))
* share finishes before all files are uploaded ([99de4e5](https://github.com/stonith404/pingvin-share/commit/99de4e57e18df54596e168a57b94c55d7a834472))
## [0.1.0](https://github.com/stonith404/pingvin-share/compare/v0.0.1...v0.1.0) (2022-10-29)
### Features
* add rate limiting ([712cfe6](https://github.com/stonith404/pingvin-share/commit/712cfe625a19dc9790cda5fbc2843fed0836b860))
* allow unauthenticated uploads ([84d29df](https://github.com/stonith404/pingvin-share/commit/84d29dff68d0ea9d76d9a35f9fb7dff95d3dda1b))
* **frontend:** remove footer ([c52a4d5](https://github.com/stonith404/pingvin-share/commit/c52a4d5e3ad717a10d15b7fe1dbf359b041c0976))
### Bug Fixes
* infinite loading when file size is small ([c2ddce6](https://github.com/stonith404/pingvin-share/commit/c2ddce62038e561d292f23fc6089562e64f1ffe9))
* only show not signed in warning if not signed in ([c6e1f07](https://github.com/stonith404/pingvin-share/commit/c6e1f07f51e9cdd914bb70fb19dd81b90a470563))
* opt out of static site generation to enable `publicRuntimeConfig` ([239b18c](https://github.com/stonith404/pingvin-share/commit/239b18cdae6367322bcdacb6b2bbaa1028295cc4))
* visitor count doesn't get incremented ([c8021a4](https://github.com/stonith404/pingvin-share/commit/c8021a42b7fb094e587325bf855fc3133b6b96b0))
### [0.0.1](https://github.com/stonith404/pingvin-share/compare/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214...v0.0.1) (2022-10-17)
### Features
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
* Added "never" expiration date ([56349c6](https://github.com/stonith404/pingvin-share/commit/56349c6f4cc739d07bcf8ad862b0868e09342883))
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))
### Bug Fixes
@@ -28,19 +445,3 @@
* upload volume path ([7522221](https://github.com/stonith404/pingvin-share/commit/7522221ee163cb0bd6144e7b924c77065f223fb9))
* wrong environment configuration for `ALLOW_REGISTRATION` ([759db40](https://github.com/stonith404/pingvin-share/commit/759db40ac9f42ff71a795ceec521a7f9531d71c9))
### Features
* add `linux/arm/v7` arch for docker image ([d9e5c28](https://github.com/stonith404/pingvin-share/commit/d9e5c286e3b53834276511227f219d0858ca0829))
* add progress indicator for uploading files ([8c84d50](https://github.com/stonith404/pingvin-share/commit/8c84d50159bdabc75a1199ffdf372b9586f67371))
* automatically detect hour format ([4e3f6be](https://github.com/stonith404/pingvin-share/commit/4e3f6be8e322929b83a35c7789078260dca9eb58))
* extract logo to component ([58efc48](https://github.com/stonith404/pingvin-share/commit/58efc48ffa559b4bfa03e381bccb552c8fb830b9))
* improve share security ([6358ac3](https://github.com/stonith404/pingvin-share/commit/6358ac3918d1af1cc05aca634d9d32a8f35d251f))
* put db and uploads in same folder ([80cdcda](https://github.com/stonith404/pingvin-share/commit/80cdcda93c385a8f5c1e22c7b84740f5d8119ef1))
* remove appwrite and add nextjs backend ([4bab33a](https://github.com/stonith404/pingvin-share/commit/4bab33ad8a79302fd94c6d92a3ddf87cdff8b214))
* remove postgres & use a single docker container ([388ac39](https://github.com/stonith404/pingvin-share/commit/388ac395ba85aae8a91ddfb5f5637a80a3e6f16b))
* replace tooltip with toast ([a33b5b3](https://github.com/stonith404/pingvin-share/commit/a33b5b37d92071e643a0bf78a9d6ecf29bebc65a))
* use system color theme ([d902aae](https://github.com/stonith404/pingvin-share/commit/d902aae03ff33d39c733cf1bce88ae58ff4cd888))

View File

@@ -1,68 +1,66 @@
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
---
# Contributing
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
## Getting started
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
## Submit a Pull Request
## 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
```
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]
$ git clone https://github.com/[your_username]/pingvin-share
```
3. Work - commit - repeat
4. Before you push your changes, make sure you run the linter and format the code.
```bash
npm run lint
npm run format
```
5. Push changes to GitHub
4. Push changes to GitHub
```
$ 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.
7. Start a Pull Request
Now submit the pull request and click on `Create pull request`.
6. Start a Pull Request
7. Now submit the pull request and click on `Create pull request`.
8. Get a code review approval/reject
</details>
## Setup project
Pingvin Share consists of a frontend and a backend.
@@ -74,20 +72,21 @@ The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
#### Setup
1. Open the `backend` folder
2. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
3. Install the dependencies with `npm install`
4. Push the database schema to the database by running `npx prisma db push`
2. Install the dependencies with `npm install`
3. Push the database schema to the database by running `npx prisma db push`
4. Seed the database with `npx prisma db seed`
5. Start the backend with `npm run dev`
### Frontend
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
#### Setup
1. Start the backend first
2. Open the `frontend` folder
3. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
4. Install the dependencies with `npm install`
5. Start the frontend with `npm run dev`
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
You're all set!

View File

@@ -1,34 +1,52 @@
FROM node:18-alpine AS frontend-builder
# Using node slim because prisma ORM needs libc for ARM builds
# Stage 1: on frontend dependency change
FROM node:19-slim AS frontend-dependencies
WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Stage 2: on frontend change
FROM node:19-slim AS frontend-builder
WORKDIR /opt/app
COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build
FROM node:18 AS backend-builder
# Stage 3: on backend dependency change
FROM node:19-slim AS backend-dependencies
WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
COPY ./backend .
RUN npx prisma generate
RUN npm run build
FROM node:18 AS runner
# Stage 4:on backend change
FROM node:19-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app
COPY ./backend .
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
RUN npx prisma generate
RUN npm run build && npm prune --production
# Stage 5: Final image
FROM node:19-slim AS runner
ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y curl openssl
WORKDIR /opt/app/frontend
ENV NODE_ENV=production
COPY --from=frontend-builder /opt/app/next.config.js .
COPY --from=frontend-builder /opt/app/public ./public
COPY --from=frontend-builder /opt/app/.next ./.next
COPY --from=frontend-builder /opt/app/node_modules ./node_modules
COPY --from=frontend-builder /opt/app/.next/standalone ./
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
COPY --from=frontend-builder /opt/app/public/img /tmp/img
WORKDIR /opt/app/backend
COPY --from=backend-builder /opt/app/node_modules ./node_modules
COPY --from=backend-builder /opt/app/dist ./dist
COPY --from=backend-builder /opt/app/prisma ./prisma
COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app
RUN npm i -g dotenv-cli
EXPOSE 3000
CMD cd frontend && dotenv -e .env.development node_modules/.bin/next start & cd backend && npm run prod
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod

165
README.md
View File

@@ -1,47 +1,158 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
---
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
## 🎪 Showcase
Demo: https://pingvin-share.dev.eliasschneider.com
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
## ✨ Features
- Create a share with files that you can access with a link
- No file size limit, only your disk will be your limit
- Set a share expiration
- Optionally secure your share with a visitor limit and a password
- Light & dark mode
- Share files using a link
- Unlimited file size (restricted only by disk space)
- Set an expiration date for shares
- Secure shares with visitor limits and passwords
- Email recipients
- Integration with ClamAV for security scans
## 🐧 Get to know Pingvin Share
- [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ 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.
1. Download the `docker-compose.yml` and `.env.example` file.
2. Rename the `.env.example` file to `.env` and change the environment variables so that they fit to your environment. If you need help with the environment variables take a look [here](#environment-variables)
3. Run `docker-compose up -d`
### Installation with Docker (recommended)
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d`
### Environment variables
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
| Variable | Description | Possible values |
| -------------------- | ------------------------------------------------------------------------------------------- | --------------- |
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
### 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
pm2 start --name="pingvin-share-frontend" npm -- run start
```
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Integrations
#### ClamAV (Docker only)
ClamAV is used to scan shares for malicious files and remove them if found.
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
3. The Pingvin Share logs should now log "ClamAV is active"
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
### Additional resources
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
### Upgrade to a new version
Just update the docker container by running `docker compose pull && docker compose up -d`
As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
#### Docker
```bash
docker compose pull
docker compose up -d
```
#### Stand-alone
1. Stop the running app
```bash
pm2 stop pingvin-share-backend pingvin-share-frontend
```
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
```bash
cd pingvin-share
# Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend
cd backend
npm run build
pm2 restart pingvin-share-backend
# Start the frontend
cd ../frontend
npm run build
pm2 restart pingvin-share-frontend
```
### Configuration
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
#### Environment variables
For installation specific configuration, you can use environment variables. The following variables are available:
##### Backend
| Variable | Default Value | Description |
| ---------------- | -------------------------------------------------- | -------------------------------------- |
| `PORT` | `8080` | The port on which the backend listens. |
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
##### Frontend
| Variable | Default Value | Description |
| --------- | ----------------------- | ---------------------------------------- |
| `PORT` | `3000` | The port on which the frontend listens. |
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
## 🖤 Contribute
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.

7
SECURITY.md Normal file
View File

@@ -0,0 +1,7 @@
# Security Policy
## Supported Versions
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
## Reporting a Vulnerability
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).

View File

@@ -1,7 +0,0 @@
# CONFIGURATION
APP_URL=http://localhost:3000
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=5000000000
# SECURITY
JWT_SECRET=random-string

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",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"]
}
}

5717
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +1,80 @@
{
"name": "pingvin-share-backend",
"version": "0.0.1",
"version": "0.17.0",
"scripts": {
"build": "nest build",
"dev": "dotenv -- nest start --watch",
"prod": "npx prisma migrate deploy && dotenv node dist/main",
"dev": "cross-env NODE_ENV=development nest start --watch",
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
"lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'",
"test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
"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"
},
"prisma": {
"seed": "ts-node prisma/seed/config.seed.ts"
},
"dependencies": {
"@nestjs/common": "^9.1.2",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.1.2",
"@nestjs/jwt": "^9.0.0",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.1.2",
"@nestjs/schedule": "^2.1.0",
"@nestjs/swagger": "^6.1.2",
"@nestjs/common": "^9.3.9",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.3.9",
"@nestjs/jwt": "^10.0.2",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.3.9",
"@nestjs/schedule": "^2.2.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/throttler": "^4.0.0",
"@prisma/client": "^4.11.0",
"archiver": "^5.3.1",
"argon2": "^0.29.1",
"argon2": "^0.30.3",
"body-parser": "^1.20.2",
"clamscan": "^2.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"class-validator": "^0.14.0",
"content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.1",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.7"
"rimraf": "^4.4.0",
"rxjs": "^7.8.0",
"sharp": "^0.31.3",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@nestjs/cli": "^9.1.4",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.1.2",
"@prisma/client": "^4.4.0",
"@nestjs/cli": "^9.2.0",
"@nestjs/schematics": "^9.0.4",
"@nestjs/testing": "^9.3.9",
"@types/archiver": "^5.3.1",
"@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.14",
"@types/express": "^4.17.17",
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.7.23",
"@types/passport-jwt": "^3.0.7",
"@types/node": "^18.15.0",
"@types/nodemailer": "^6.4.7",
"@types/passport-jwt": "^3.0.8",
"@types/qrcode-svg": "^1.1.1",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"cross-env": "^7.0.3",
"dotenv-cli": "^6.0.0",
"eslint": "^8.25.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2",
"prettier": "^2.7.1",
"prisma": "^4.4.0",
"prettier": "^2.8.4",
"prisma": "^4.11.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.1.0",
"typescript": "^4.8.4"
"ts-loader": "^9.4.2",
"tsconfig-paths": "4.1.2",
"typescript": "^4.9.5",
"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,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
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,
"creatorId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "ShareRecipient" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"shareId" TEXT NOT NULL,
CONSTRAINT "ShareRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
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,
"creatorId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
-- 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 NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "password", "updatedAt", 'user-' || User.id as "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,17 @@
-- 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,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Config" ("description", "key", "locked", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "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,2 @@
-- AlterTable
ALTER TABLE "Share" ADD COLUMN "description" TEXT;

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "LoginToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "LoginToken_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 NOT NULL,
"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", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,56 @@
/*
Warnings:
- Added the required column `category` 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,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
UPDATE config SET category = "internal" WHERE key = "SETUP_FINISHED";
UPDATE config SET category = "internal" WHERE key = "TOTP_SECRET";
UPDATE config SET category = "internal" WHERE key = "JWT_SECRET";
UPDATE config SET category = "general" WHERE key = "APP_URL";
UPDATE config SET category = "general" WHERE key = "SHOW_HOME_PAGE";
UPDATE config SET category = "share" WHERE key = "ALLOW_REGISTRATION";
UPDATE config SET category = "share" WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
UPDATE config SET category = "share" WHERE key = "MAX_FILE_SIZE";
UPDATE config SET category = "email" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
UPDATE config SET category = "email" WHERE key = "EMAIL_MESSAGE";
UPDATE config SET category = "email" WHERE key = "EMAIL_SUBJECT";
UPDATE config SET category = "email" WHERE key = "SMTP_HOST";
UPDATE config SET category = "email" WHERE key = "SMTP_PORT";
UPDATE config SET category = "email" WHERE key = "SMTP_EMAIL";
UPDATE config SET category = "email" WHERE key = "SMTP_USERNAME";
UPDATE config SET category = "email" WHERE key = "SMTP_PASSWORD";
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
);
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category" 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,21 @@
/*
Warnings:
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
DROP TABLE "RefreshToken";
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

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

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

@@ -4,7 +4,7 @@ generator client {
datasource db {
provider = "sqlite"
url = "file:../data/pingvin-share.db"
url = env("DATABASE_URL")
}
model User {
@@ -12,17 +12,25 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
password String
firstName String?
lastName String?
username String @unique
email String @unique
password String
isAdmin Boolean @default(false)
shares Share[]
refreshTokens RefreshToken[]
loginTokens LoginToken[]
reverseShares ReverseShare[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpSecret String?
resetPasswordToken ResetPasswordToken?
}
model RefreshToken {
token String @id @default(uuid())
id String @id @default(uuid())
token String @unique @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
@@ -31,19 +39,71 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model LoginToken {
token String @id @default(uuid())
createdAt DateTime @default(now())
expiresAt DateTime
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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 Share {
id String @id @default(uuid())
createdAt DateTime @default(now())
uploadLocked Boolean @default(false)
isZipReady Boolean @default(false)
views Int @default(0)
expiration DateTime
uploadLocked Boolean @default(false)
isZipReady Boolean @default(false)
views Int @default(0)
expiration DateTime
description String?
removedReason String?
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity?
recipients ShareRecipient[]
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])
security ShareSecurity?
files File[]
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shares Share[]
}
model ShareRecipient {
id String @id @default(uuid())
email String
shareId String
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model File {
@@ -67,3 +127,19 @@ model ShareSecurity {
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model Config {
updatedAt DateTime @updatedAt
name String
category String
type String
defaultValue String @default("")
value String?
obscured Boolean @default(false)
secret Boolean @default(true)
locked Boolean @default(false)
order Int
@@id([name, category])
}

View File

@@ -0,0 +1,225 @@
import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto";
const configVariables: ConfigVariables = {
internal: {
jwtSecret: {
type: "string",
defaultValue: crypto.randomBytes(256).toString("base64"),
locked: true,
},
},
general: {
appName: {
type: "string",
defaultValue: "Pingvin Share",
secret: false,
},
appUrl: {
type: "string",
defaultValue: "http://localhost:3000",
secret: false,
},
showHomePage: {
type: "boolean",
defaultValue: "true",
secret: false,
},
},
share: {
allowRegistration: {
type: "boolean",
defaultValue: "true",
secret: false,
},
allowUnauthenticatedShares: {
type: "boolean",
defaultValue: "false",
secret: false,
},
maxSize: {
type: "number",
defaultValue: "1000000000",
secret: false,
},
zipCompressionLevel: {
type: "number",
defaultValue: "9",
},
},
email: {
enableShareEmailRecipients: {
type: "boolean",
defaultValue: "false",
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,
},
},
};
type ConfigVariables = {
[category: string]: {
[variable: string]: Omit<
Prisma.ConfigCreateInput,
"name" | "category" | "order"
>;
};
};
const prisma = new PrismaClient({
datasources: {
db: {
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++;
}
}
}
async function migrateConfigVariables() {
const existingConfigVariables = await prisma.config.findMany();
for (const existingConfigVariable of existingConfigVariables) {
const configVariable =
configVariables[existingConfigVariable.category]?.[
existingConfigVariable.name
];
if (!configVariable) {
await prisma.config.delete({
where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
});
// Update the config variable if the metadata changed
} else if (
JSON.stringify({
...configVariable,
name: existingConfigVariable.name,
category: existingConfigVariable.category,
value: existingConfigVariable.value,
}) != JSON.stringify(existingConfigVariable)
) {
await prisma.config.update({
where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
data: {
...configVariable,
name: existingConfigVariable.name,
category: existingConfigVariable.category,
value: existingConfigVariable.value,
},
});
}
}
}
seedConfigVariables()
.then(() => migrateConfigVariables())
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -1,27 +1,43 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./jobs/jobs.service";
import { FileController } from "./file/file.controller";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { ConfigModule } from "./config/config.module";
import { EmailModule } from "./email/email.module";
import { FileModule } from "./file/file.module";
import { JobsModule } from "./jobs/jobs.module";
import { PrismaModule } from "./prisma/prisma.module";
import { PrismaService } from "./prisma/prisma.service";
import { ShareController } from "./share/share.controller";
import { ShareModule } from "./share/share.module";
import { UserController } from "./user/user.controller";
import { UserModule } from "./user/user.module";
import { ClamScanModule } from "./clamscan/clamscan.module";
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
@Module({
imports: [
AuthModule,
ShareModule,
FileModule,
EmailModule,
PrismaModule,
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule,
JobsModule,
UserModule,
ThrottlerModule.forRoot({
ttl: 60,
limit: 100,
}),
ScheduleModule.forRoot(),
ClamScanModule,
ReverseShareModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
providers: [PrismaService, JobsService],
controllers: [UserController, ShareController, FileController],
})
export class AppModule {}

View File

@@ -3,41 +3,191 @@ import {
Controller,
ForbiddenException,
HttpCode,
Param,
Patch,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service";
import { GetUser } from "./decorator/getUser.decorator";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
import { TokenDTO } from "./dto/token.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
import { JwtGuard } from "./guard/jwt.guard";
@Controller("auth")
export class AuthController {
constructor(
private authService: AuthService,
private authTotpService: AuthTotpService,
private config: ConfigService
) {}
@Post("signUp")
signUp(@Body() dto: AuthRegisterDTO) {
if (this.config.get("ALLOW_REGISTRATION") == "false")
@Throttle(10, 5 * 60)
async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
) {
if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed");
return this.authService.signUp(dto);
const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
return result;
}
@Post("signIn")
@Throttle(10, 5 * 60)
@HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) {
return this.authService.signIn(dto);
async signIn(
@Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
}
return result;
}
@Post("signIn/totp")
@Throttle(10, 5 * 60)
@HttpCode(200)
async signInTotp(
@Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
response,
result.refreshToken,
result.accessToken
);
return new TokenDTO().from(result);
}
@Post("resetPassword/:email")
@Throttle(5, 5 * 60)
@HttpCode(204)
async requestResetPassword(@Param("email") email: string) {
return await 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")
@UseGuards(JwtGuard)
async updatePassword(
@GetUser() user: User,
@Res({ passthrough: true }) response: Response,
@Body() dto: UpdatePasswordDTO
) {
const result = await this.authService.updatePassword(
user,
dto.oldPassword,
dto.password
);
response = this.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result);
}
@Post("token")
@HttpCode(200)
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
async refreshAccessToken(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken(
body.refreshToken
request.cookies.refresh_token
);
return { accessToken };
response = this.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken });
}
@Post("signOut")
async signOut(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {
path: "/api/auth/token",
httpOnly: true,
maxAge: -1,
});
}
@Post("totp/enable")
@UseGuards(JwtGuard)
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
return this.authTotpService.enableTotp(user, body.password);
}
@Post("totp/verify")
@UseGuards(JwtGuard)
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
return this.authTotpService.verifyTotp(user, body.password, body.code);
}
@Post("totp/disable")
@UseGuards(JwtGuard)
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code);
}
private addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string
) {
if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
return response;
}
}

View File

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

View File

@@ -1,14 +1,16 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
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 moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
@@ -18,61 +20,164 @@ export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private config: ConfigService
private config: ConfigService,
private emailService: EmailService
) {}
async signUp(dto: AuthRegisterDTO) {
const isFirstUser = (await this.prisma.user.count()) == 0;
const hash = await argon.hash(dto.password);
try {
const user = await this.prisma.user.create({
data: {
email: dto.email,
username: dto.username,
password: hash,
isAdmin: isFirstUser,
},
});
const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
throw new BadRequestException("Credentials taken");
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
);
}
}
}
}
async signIn(dto: AuthSignInDTO) {
const user = await this.prisma.user.findUnique({
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
email: dto.email,
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const accessToken = await this.createAccessToken(user);
const refreshToken = await this.createRefreshToken(user.id);
// TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled
if (user.totpVerified) {
const loginToken = await this.createLoginToken(user.id);
return { loginToken };
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
}
async createAccessToken(user: User) {
async requestResetPassword(email: string) {
const user = await this.prisma.user.findFirst({
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, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password");
const hash = await argon.hash(newPassword);
await this.prisma.refreshToken.deleteMany({
where: { userId: user.id },
});
await this.prisma.user.update({
where: { id: user.id },
data: { password: hash },
});
return this.createRefreshToken(user.id);
}
async createAccessToken(user: User, refreshTokenId: string) {
return this.jwtService.sign(
{
sub: user.id,
email: user.email,
isAdmin: user.isAdmin,
refreshTokenId,
},
{
expiresIn: "15min",
secret: this.config.get("JWT_SECRET"),
secret: this.config.get("internal.jwtSecret"),
}
);
}
async signOut(accessToken: string) {
const { refreshTokenId } =
(this.jwtService.decode(accessToken) as {
refreshTokenId: string;
}) || {};
if (refreshTokenId) {
await this.prisma.refreshToken
.delete({ where: { id: refreshTokenId } })
.catch((e) => {
// Ignore error if refresh token doesn't exist
if (e.code != "P2025") throw e;
});
}
}
async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
@@ -82,16 +187,27 @@ export class AuthService {
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
throw new UnauthorizedException();
return this.createAccessToken(refreshTokenMetaData.user);
return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
);
}
async createRefreshToken(userId: string) {
const refreshToken = (
await this.prisma.refreshToken.create({
data: { userId, expiresAt: moment().add(3, "months").toDate() },
const { id, token } = await this.prisma.refreshToken.create({
data: { userId, expiresAt: moment().add(3, "months").toDate() },
});
return { refreshTokenId: id, refreshToken: token };
}
async createLoginToken(userId: string) {
const loginToken = (
await this.prisma.loginToken.create({
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
})
).token;
return refreshToken;
return loginToken;
}
}

View File

@@ -0,0 +1,187 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import * as argon from "argon2";
import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@Injectable()
export class AuthTotpService {
constructor(
private prisma: PrismaService,
private authService: AuthService,
private config: ConfigService
) {}
async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({
where: {
token: dto.loginToken,
},
});
if (!token || token.userId != user.id || token.used)
throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const expected = authenticator.generate(totpSecret);
if (dto.totp !== expected) {
throw new BadRequestException("Invalid code");
}
// Set the login token to used
await this.prisma.loginToken.update({
where: { token: token.token },
data: { used: true },
});
const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken(
user,
refreshTokenId
);
return { accessToken, refreshToken };
}
async enableTotp(user: User, password: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
// Check if we have a secret already
const { totpVerified } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpVerified: true },
});
if (totpVerified) {
throw new BadRequestException("TOTP is already enabled");
}
// TODO: Maybe make the issuer configurable with env vars?
const secret = authenticator.generateSecret();
const otpURL = totp.keyuri(
user.username || user.email,
this.config.get("general.appName"),
secret
);
await this.prisma.user.update({
where: { id: user.id },
data: {
totpEnabled: true,
totpSecret: secret,
},
});
// TODO: Maybe we should generate the QR code on the client rather than the server?
const qrCode = new qrcode({
content: otpURL,
container: "svg-viewbox",
join: true,
}).svg();
return {
totpAuthUrl: otpURL,
totpSecret: secret,
qrCode:
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
};
}
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
async verifyTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not in progress");
}
const expected = authenticator.generate(totpSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: true,
},
});
return true;
}
async disableTotp(user: User, password: string, code: string) {
if (!(await argon.verify(user.password, password)))
throw new ForbiddenException("Invalid password");
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const expected = authenticator.generate(totpSecret);
if (code !== expected) {
throw new BadRequestException("Invalid code");
}
await this.prisma.user.update({
where: { id: user.id },
data: {
totpVerified: false,
totpEnabled: false,
totpSecret: null,
},
});
return true;
}
}

View File

@@ -1,3 +1,8 @@
import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends UserDTO {}
export class AuthRegisterDTO extends PickType(UserDTO, [
"email",
"username",
"password",
] as const) {}

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()
refreshToken: string;
}

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

@@ -0,0 +1,15 @@
import { Expose, plainToClass } from "class-transformer";
export class TokenDTO {
@Expose()
accessToken: string;
@Expose()
refreshToken: string;
from(partial: Partial<TokenDTO>) {
return plainToClass(TokenDTO, partial, {
excludeExtraneousValues: true,
});
}
}

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 UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
oldPassword: string;
}

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 VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) {
@IsString()
code: string;
}

View File

@@ -0,0 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
@Injectable()
export class AdministratorGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const { user }: { user: User } = context.switchToHttp().getRequest();
if (!user) return false;
return user.isAdmin;
}
}

View File

@@ -1,7 +1,17 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class JwtGuard extends AuthGuard("jwt") {
constructor() {
constructor(private config: ConfigService) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return (await super.canActivate(context)) as boolean;
} catch {
return this.config.get("share.allowUnauthenticatedShares");
}
}
}

View File

@@ -1,24 +1,30 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Request } from "express";
import { Strategy } from "passport-jwt";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
config.get("internal.jwtSecret");
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get("JWT_SECRET"),
jwtFromRequest: JwtStrategy.extractJWT,
secretOrKey: config.get("internal.jwtSecret"),
});
}
private static extractJWT(req: Request) {
if (!req.cookies.access_token) return null;
return req.cookies.access_token;
}
async validate(payload: { sub: string }) {
const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
return user;
}
}

View File

@@ -0,0 +1,10 @@
import { forwardRef, Module } from "@nestjs/common";
import { FileModule } from "src/file/file.module";
import { ClamScanService } from "./clamscan.service";
@Module({
imports: [forwardRef(() => FileModule)],
providers: [ClamScanService],
exports: [ClamScanService],
})
export class ClamScanModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable, Logger } from "@nestjs/common";
import * as NodeClam from "clamscan";
import * as fs from "fs";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
const clamscanConfig = {
clamdscan: {
host: CLAMAV_HOST,
port: CLAMAV_PORT,
localFallback: false,
},
preference: "clamdscan",
};
@Injectable()
export class ClamScanService {
private readonly logger = new Logger(ClamScanService.name);
constructor(
private fileService: FileService,
private prisma: PrismaService
) {}
private ClamScan: Promise<NodeClam | null> = new NodeClam()
.init(clamscanConfig)
.then((res) => {
this.logger.log("ClamAV is active");
return res;
})
.catch(() => {
this.logger.log("ClamAV is not active");
return null;
});
async check(shareId: string) {
const clamScan = await this.ClamScan;
if (!clamScan) return [];
const infectedFiles = [];
const files = fs
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
.filter((file) => file != "archive.zip");
for (const fileId of files) {
const { isInfected } = await clamScan
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
.catch(() => {
this.logger.log("ClamAV is not active");
return { isInfected: false };
});
const fileName = (
await this.prisma.file.findUnique({ where: { id: fileId } })
).name;
if (isInfected) {
infectedFiles.push({ id: fileId, name: fileName });
}
}
return infectedFiles;
}
async checkAndRemove(shareId: string) {
const infectedFiles = await this.check(shareId);
if (infectedFiles.length > 0) {
await this.fileService.deleteAllFiles(shareId);
await this.prisma.file.deleteMany({ where: { shareId } });
const fileNames = infectedFiles.map((file) => file.name).join(", ");
await this.prisma.share.update({
where: { id: shareId },
data: {
removedReason: `Your share got removed because the file(s) ${fileNames} are malicious.`,
},
});
this.logger.warn(
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
);
}
}
}

View File

@@ -0,0 +1,75 @@
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 { JwtGuard } from "src/auth/guard/jwt.guard";
import { EmailService } from "src/email/email.service";
import { ConfigService } from "./config.service";
import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto";
import { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto";
import { LogoService } from "./logo.service";
@Controller("configs")
export class ConfigController {
constructor(
private configService: ConfigService,
private logoService: LogoService,
private emailService: EmailService
) {}
@Get()
@SkipThrottle()
async list() {
return new ConfigDTO().fromList(await this.configService.list());
}
@Get("admin/:category")
@UseGuards(JwtGuard, AdministratorGuard)
async getByCategory(@Param("category") category: string) {
return new AdminConfigDTO().fromList(
await this.configService.getByCategory(category)
);
}
@Patch("admin")
@UseGuards(JwtGuard, AdministratorGuard)
async updateMany(@Body() data: UpdateConfigDTO[]) {
return new AdminConfigDTO().fromList(
await this.configService.updateMany(data)
);
}
@Post("admin/testEmail")
@UseGuards(JwtGuard, AdministratorGuard)
async testEmail(@Body() { email }: TestEmailDTO) {
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

@@ -0,0 +1,25 @@
import { Global, Module } from "@nestjs/common";
import { EmailModule } from "src/email/email.module";
import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service";
import { LogoService } from "./logo.service";
@Global()
@Module({
imports: [EmailModule],
providers: [
{
provide: "CONFIG_VARIABLES",
useFactory: async (prisma: PrismaService) => {
return await prisma.config.findMany();
},
inject: [PrismaService],
},
ConfigService,
LogoService,
],
controllers: [ConfigController],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@@ -0,0 +1,110 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Config } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ConfigService {
constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService
) {}
get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter(
(variable) => `${variable.category}.${variable.name}` == key
)[0];
if (!configVariable) throw new Error(`Config variable ${key} not found`);
const value = configVariable.value ?? configVariable.defaultValue;
if (configVariable.type == "number") return parseInt(value);
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text")
return value;
}
async getByCategory(category: string) {
const configVariables = await this.prisma.config.findMany({
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() {
const configVariables = await this.prisma.config.findMany({
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 }[]) {
const response: Config[] = [];
for (const variable of data) {
response.push(await this.update(variable.key, variable.value));
}
return response;
}
async update(key: string, value: string | number | boolean) {
const configVariable = await this.prisma.config.findUnique({
where: {
name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
});
if (!configVariable || configVariable.locked)
throw new NotFoundException("Config variable not found");
if (value === "") {
value = null;
} else if (
typeof value != configVariable.type &&
typeof value == "string" &&
configVariable.type != "text"
) {
throw new BadRequestException(
`Config variable must be of type ${configVariable.type}`
);
}
const updatedVariable = await this.prisma.config.update({
where: {
name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
data: { value: value === null ? null : value.toString() },
});
this.configVariables = await this.prisma.config.findMany();
return updatedVariable;
}
}

View File

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

View File

@@ -0,0 +1,18 @@
import { Expose, plainToClass } from "class-transformer";
export class ConfigDTO {
@Expose()
key: string;
@Expose()
value: string;
@Expose()
type: string;
fromList(partial: Partial<ConfigDTO>[]) {
return partial.map((part) =>
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from "class-validator";
export class TestEmailDTO {
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from "class-validator";
class UpdateConfigDTO {
@IsString()
key: string;
@IsNotEmpty()
value: string | number | boolean;
}
export default UpdateConfigDTO;

View File

@@ -0,0 +1,32 @@
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) {
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "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

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,133 @@
import {
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { User } from "@prisma/client";
import * as moment from "moment";
import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class EmailService {
constructor(private config: ConfigService) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() {
if (!this.config.get("smtp.enabled"))
throw new InternalServerErrorException("SMTP is disabled");
return nodemailer.createTransport({
host: this.config.get("smtp.host"),
port: this.config.get("smtp.port"),
secure: this.config.get("smtp.port") == 465,
auth: {
user: this.config.get("smtp.username"),
pass: this.config.get("smtp.password"),
},
});
}
private async sendMail(email: string, subject: string, text: string) {
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");
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
await this.sendMail(
recipientEmail,
this.config.get("email.shareRecipientsSubject"),
this.config
.get("email.shareRecipientsMessage")
.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("{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) {
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.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,84 +1,50 @@
import {
Body,
Controller,
Get,
Param,
ParseFilePipeBuilder,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { SkipThrottle } from "@nestjs/throttler";
import * as contentDisposition from "content-disposition";
import { Response } from "express";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
import { ShareDTO } from "src/share/dto/share.dto";
import { CreateShareGuard } from "src/share/guard/createShare.guard";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service";
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
@Controller("shares/:shareId/files")
export class FileController {
constructor(private fileService: FileService) {}
@Post()
@UseGuards(JwtGuard, ShareOwnerGuard)
@UseInterceptors(
FileInterceptor("file", {
dest: "./data/uploads/_temp/",
})
)
@SkipThrottle()
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async create(
@UploadedFile(
new ParseFilePipeBuilder()
.addMaxSizeValidator({
maxSize: parseInt(process.env.MAX_FILE_SIZE),
})
.build()
)
file: Express.Multer.File,
@Query() query: any,
@Body() body: string,
@Param("shareId") shareId: string
) {
// Fixes file names with special characters
file.originalname = Buffer.from(file.originalname, "latin1").toString(
"utf8"
const { id, name, chunkIndex, totalChunks } = query;
// Data can be empty if the file is empty
const data = body.toString().split(",")[1] ?? "";
return await this.fileService.create(
data,
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
{ id, name },
shareId
);
return new ShareDTO().from(await this.fileService.create(file, shareId));
}
@Get(":fileId/download")
@UseGuards(ShareSecurityGuard)
async getFileDownloadUrl(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
return { url };
}
@Get("zip/download")
@UseGuards(ShareSecurityGuard)
async getZipArchiveDownloadURL(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
) {
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
res.set({
"Content-Type": "application/zip",
});
return { url };
}
@Get("zip")
@UseGuards(FileDownloadGuard)
@UseGuards(FileSecurityGuard)
async getZip(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string
@@ -86,25 +52,32 @@ export class FileController {
const zip = this.fileService.getZip(shareId);
res.set({
"Content-Type": "application/zip",
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}"`,
"Content-Disposition": contentDisposition(`${shareId}.zip`),
});
return new StreamableFile(zip);
}
@Get(":fileId")
@UseGuards(FileDownloadGuard)
@UseGuards(FileSecurityGuard)
async getFile(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
@Param("fileId") fileId: string
@Param("fileId") fileId: string,
@Query("download") download = "true"
) {
const file = await this.fileService.get(shareId, fileId);
res.set({
const headers = {
"Content-Type": file.metaData.mimeType,
"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);
}

View File

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

View File

@@ -1,14 +1,17 @@
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "crypto";
import * as crypto from "crypto";
import * as fs from "fs";
import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable()
export class FileService {
@@ -18,32 +21,88 @@ export class FileService {
private config: ConfigService
) {}
async create(file: Express.Multer.File, shareId: string) {
async create(
data: string,
chunk: { index: number; total: number },
file: { id?: string; name: string },
shareId: string
) {
if (!file.id) file.id = crypto.randomUUID();
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { files: true, reverseShare: true },
});
if (share.uploadLocked)
throw new BadRequestException("Share is already completed");
const fileId = randomUUID();
let diskFileSize: number;
try {
diskFileSize = fs.statSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
).size;
} catch {
diskFileSize = 0;
}
await fs.promises.mkdir(`./data/uploads/shares/${shareId}`, {
recursive: true,
});
fs.promises.rename(
`./data/uploads/_temp/${file.filename}`,
`./data/uploads/shares/${shareId}/${fileId}`
// If the sent chunk index and the expected chunk index doesn't match throw an error
const chunkSize = 10 * 1024 * 1024; // 10MB
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
if (expectedChunkIndex != chunk.index)
throw new BadRequestException({
message: "Unexpected chunk received",
error: "unexpected_chunk_index",
expectedChunkIndex,
});
const buffer = Buffer.from(data, "base64");
// Check if share size limit is exceeded
const fileSizeSum = share.files.reduce(
(n, { size }) => n + parseInt(size),
0
);
return await this.prisma.file.create({
data: {
id: fileId,
name: file.originalname,
size: file.size.toString(),
share: { connect: { id: shareId } },
},
});
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
if (
shareSizeSum > this.config.get("share.maxSize") ||
(share.reverseShare?.maxShareSize &&
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
) {
throw new HttpException(
"Max share size exceeded",
HttpStatus.PAYLOAD_TOO_LARGE
);
}
fs.appendFileSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer
);
const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) {
fs.renameSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
);
const fileSize = fs.statSync(
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
).size;
await this.prisma.file.create({
data: {
id: file.id,
name: file.name,
size: fileSize.toString(),
share: { connect: { id: shareId } },
},
});
}
return file;
}
async get(shareId: string, fileId: string) {
@@ -53,9 +112,7 @@ export class FileService {
if (!fileMetaData) throw new NotFoundException("File not found");
const file = fs.createReadStream(
`./data/uploads/shares/${shareId}/${fileId}`
);
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
return {
metaData: {
@@ -68,46 +125,13 @@ export class FileService {
}
async deleteAllFiles(shareId: string) {
await fs.promises.rm(`./data/uploads/shares/${shareId}`, {
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true,
force: true,
});
}
getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${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;
}
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
}
}

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

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

View File

@@ -1,13 +1,19 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import * as fs from "fs";
import * as moment from "moment";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import * as moment from "moment";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable()
export class JobsService {
private readonly logger = new Logger(JobsService.name);
constructor(
private prisma: PrismaService,
private reverseShareService: ReverseShareService,
private fileService: FileService
) {}
@@ -31,14 +37,83 @@ export class JobsService {
await this.fileService.deleteAllFiles(expiredShare.id);
}
console.log(`job: deleted ${expiredShares.length} expired shares`);
if (expiredShares.length > 0) {
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
}
}
@Cron("0 * * * *")
async deleteExpiredRefreshTokens() {
const expiredShares = await this.prisma.refreshToken.deleteMany({
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 0 * * *")
deleteTemporaryFiles() {
let filesDeleted = 0;
const shareDirectories = fs
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) {
const stats = fs.statSync(
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
);
const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day")
.isBefore(moment());
if (isOlderThanOneDay) {
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
filesDeleted++;
}
}
}
this.logger.log(`Deleted ${filesDeleted} temporary files`);
}
@Cron("0 * * * *")
async deleteExpiredTokens() {
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() } },
});
console.log(`job: deleted ${expiredShares.count} expired refresh tokens`);
const { count: resetPasswordTokenCount } =
await this.prisma.resetPasswordToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0) {
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
}
}
}

View File

@@ -1,15 +1,38 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser";
import * as fs from "fs";
import { AppModule } from "./app.module";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
app.use(cookieParser());
app.set("trust proxy", true);
await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
recursive: true,
});
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();

View File

@@ -1,18 +1,17 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "@prisma/client";
import { DATABASE_URL } from "../constants";
@Injectable()
export class PrismaService extends PrismaClient {
constructor(config: ConfigService) {
constructor() {
super({
datasources: {
db: {
url: "file:../data/pingvin-share.db",
url: DATABASE_URL,
},
},
});
console.log(config.get("DB_URL"));
super.$connect().then(() => console.info("Connected to the database"));
}
}

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,97 @@
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 { 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 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,11 +1,19 @@
import { Type } from "class-transformer";
import { IsString, Length, Matches, ValidateNested } from "class-validator";
import {
IsEmail,
IsOptional,
IsString,
Length,
Matches,
MaxLength,
ValidateNested,
} from "class-validator";
import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO {
@IsString()
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
message: "ID only can contain letters, numbers, underscores and hyphens",
message: "ID can only contain letters, numbers, underscores and hyphens",
})
@Length(3, 50)
id: string;
@@ -13,6 +21,13 @@ export class CreateShareDTO {
@IsString()
expiration: string;
@MaxLength(512)
@IsOptional()
description: string;
@IsEmail({}, { each: true })
recipients: string[];
@ValidateNested()
@Type(() => ShareSecurityDTO)
security: ShareSecurityDTO;

View File

@@ -1,13 +1,26 @@
import { Expose, plainToClass } from "class-transformer";
import { Expose, plainToClass, Type } from "class-transformer";
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()
views: number;
@Expose()
createdAt: Date;
@Expose()
recipients: string[];
@Expose()
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
files: Omit<FileDTO, "share" | "from">[];
from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
}

View File

@@ -1,6 +1,6 @@
import { Expose, plainToClass, Type } from "class-transformer";
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
import { FileDTO } from "src/file/dto/file.dto";
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
export class ShareDTO {
@Expose()
@@ -14,8 +14,14 @@ export class ShareDTO {
files: FileDTO[];
@Expose()
@Type(() => AuthSignInDTO)
creator: AuthSignInDTO;
@Type(() => PublicUserDTO)
creator: PublicUserDTO;
@Expose()
description: string;
@Expose()
hasPassword: boolean;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });

View File

@@ -1,3 +1,7 @@
import { IsOptional, IsString } from "class-validator";
export class SharePasswordDto {
@IsString()
@IsOptional()
password: string;
}

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

@@ -28,6 +28,8 @@ export class ShareOwnerGuard implements CanActivate {
if (!share) throw new NotFoundException("Share not found");
if (!share.creatorId) return true;
return share.creatorId == (request.user as User).id;
}
}

View File

@@ -5,7 +5,6 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
@Injectable()
export class ShareSecurityGuard implements CanActivate {
constructor(
private reflector: Reflector,
private shareService: ShareService,
private prisma: PrismaService
) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
? 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 },
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
if (
!share ||
(moment().isAfter(share.expiration) &&
moment(share.expiration).unix() !== 0)
!moment(share.expiration).isSame(0))
)
throw new NotFoundException("Share not found");
@@ -47,7 +47,7 @@ export class ShareSecurityGuard implements CanActivate {
"share_password_required"
);
if (!this.shareService.verifyShareToken(shareId, shareToken))
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
throw new ForbiddenException(
"Share token required",
"share_token_required"

View File

@@ -1,7 +1,6 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
)
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;
}
}

View File

@@ -6,9 +6,13 @@ import {
HttpCode,
Param,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { Request, Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateShareDTO } from "./dto/createShare.dto";
@@ -16,11 +20,11 @@ import { MyShareDTO } from "./dto/myShare.dto";
import { ShareDTO } from "./dto/share.dto";
import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto";
import { CreateShareGuard } from "./guard/createShare.guard";
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service";
@Controller("shares")
export class ShareController {
constructor(private shareService: ShareService) {}
@@ -46,9 +50,16 @@ export class ShareController {
}
@Post()
@UseGuards(JwtGuard)
async create(@Body() body: CreateShareDTO, @GetUser() user: User) {
return new ShareDTO().from(await this.shareService.create(body, user));
@UseGuards(CreateShareGuard)
async create(
@Body() body: CreateShareDTO,
@Req() request: Request,
@GetUser() user: User
) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.create(body, user, reverse_share_token)
);
}
@Delete(":id")
@@ -59,20 +70,35 @@ export class ShareController {
@Post(":id/complete")
@HttpCode(202)
@UseGuards(JwtGuard, ShareOwnerGuard)
async complete(@Param("id") id: string) {
return new ShareDTO().from(await this.shareService.complete(id));
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token)
);
}
@Throttle(10, 60)
@Get("isShareIdAvailable/:id")
async isShareIdAvailable(@Param("id") id: string) {
return this.shareService.isShareIdAvailable(id);
}
@HttpCode(200)
@Throttle(20, 5 * 60)
@UseGuards(ShareTokenSecurity)
@Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
return this.shareService.getShareToken(id, body.password);
async getShareToken(
@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,11 +1,20 @@
import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ClamScanModule } from "src/clamscan/clamscan.module";
import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module";
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
import { ShareController } from "./share.controller";
import { ShareService } from "./share.service";
@Module({
imports: [JwtModule.register({}), forwardRef(() => FileModule)],
imports: [
JwtModule.register({}),
EmailModule,
ClamScanModule,
ReverseShareModule,
forwardRef(() => FileModule),
],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],

View File

@@ -4,15 +4,19 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Share, User } from "@prisma/client";
import * as archiver from "archiver";
import * as argon from "argon2";
import * as fs from "fs";
import * as moment from "moment";
import { ClamScanService } from "src/clamscan/clamscan.service";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable()
@@ -20,11 +24,14 @@ export class ShareService {
constructor(
private prisma: PrismaService,
private fileService: FileService,
private emailService: EmailService,
private config: ConfigService,
private jwtService: JwtService
private jwtService: JwtService,
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)
throw new BadRequestException("Share id already in use");
@@ -35,41 +42,69 @@ export class ShareService {
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;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
let expirationDate: Date;
// Throw error if expiration date is now
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
throw new BadRequestException("Invalid expiration date");
// If share is created by a reverse share token override the expiration date
const reverseShare = await this.reverseShareService.getByToken(
reverseShareToken
);
if (reverseShare) {
expirationDate = reverseShare.shareExpiration;
} else {
expirationDate = moment(0).toDate();
// We have to add an exception for "never" (since moment won't like that)
if (share.expiration !== "never") {
expirationDate = moment()
.add(
share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
} else {
expirationDate = moment(0).toDate();
}
}
return await this.prisma.share.create({
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
recursive: true,
});
const shareTuple = await this.prisma.share.create({
data: {
...share,
expiration: expirationDate,
creator: { connect: { id: user.id } },
creator: { connect: user ? { id: user.id } : undefined },
security: { create: share.security },
recipients: {
create: share.recipients
? share.recipients.map((email) => ({ email }))
: [],
},
},
});
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) {
const path = `./data/uploads/shares/${shareId}`;
const path = `${SHARE_DIRECTORY}/${shareId}`;
const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", {
zlib: { level: 9 },
zlib: { level: this.config.get("share.zipCompressionLevel") },
});
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
@@ -83,30 +118,71 @@ export class ShareService {
await archive.finalize();
}
async complete(id: string) {
async complete(id: string, reverseShareToken?: string) {
const share = await this.prisma.share.findUnique({
where: { id },
include: {
files: true,
recipients: true,
creator: true,
reverseShare: { include: { creator: true } },
},
});
if (await this.isShareCompleted(id))
throw new BadRequestException("Share already completed");
const moreThanOneFileInShare =
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
if (!moreThanOneFileInShare)
if (share.files.length == 0)
throw new BadRequestException(
"You need at least on file in your share to complete it."
);
this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
);
// Asynchronously create a zip of all files
if (share.files.length > 1)
this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
);
return await this.prisma.share.update({
// Send email for each recipient
for (const recipient of share.recipients) {
await this.emailService.sendMailToShareRecipients(
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
);
}
// Check if any file is malicious with ClamAV
void this.clamScanService.checkAndRemove(share.id);
if (share.reverseShare) {
await this.prisma.reverseShare.update({
where: { token: reverseShareToken },
data: { remainingUses: { decrement: 1 } },
});
}
return this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
});
}
async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({
const shares = await this.prisma.share.findMany({
where: {
creator: { id: userId },
uploadLocked: true,
@@ -119,22 +195,36 @@ export class ShareService {
orderBy: {
expiration: "desc",
},
include: { recipients: true, files: true },
});
return shares.map((share) => {
return {
...share,
recipients: share.recipients.map((recipients) => recipients.email),
};
});
}
async get(id: string) {
const share: any = await this.prisma.share.findUnique({
async get(id: string): Promise<any> {
const share = await this.prisma.share.findUnique({
where: { id },
include: {
files: true,
creator: true,
security: true,
},
});
if (share.removedReason)
throw new NotFoundException(share.removedReason, "share_removed");
if (!share || !share.uploadLocked)
throw new NotFoundException("Share not found");
return share;
return {
...share,
hasPassword: !!share.security?.password,
};
}
async getMetaData(id: string) {
@@ -154,6 +244,8 @@ export class ShareService {
});
if (!share) throw new NotFoundException("Share not found");
if (!share.creatorId)
throw new ForbiddenException("Anonymous shares can't be deleted");
await this.fileService.deleteAllFiles(shareId);
await this.prisma.share.delete({ where: { id: shareId } });
@@ -186,12 +278,20 @@ export class ShareService {
if (
share?.security?.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);
await this.increaseViewCount(share);
return { token };
return token;
}
async generateShareToken(shareId: string) {
@@ -204,7 +304,7 @@ export class ShareService {
},
{
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("JWT_SECRET"),
secret: this.config.get("internal.jwtSecret"),
}
);
}
@@ -216,7 +316,7 @@ export class ShareService {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
secret: this.config.get("internal.jwtSecret"),
// Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0),
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,37 @@
import { Expose, plainToClass } from "class-transformer";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
import { IsEmail, Length, Matches, MinLength } from "class-validator";
export class UserDTO {
@Expose()
id: string;
@Expose()
firstName: string;
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
message: "Username can only contain letters, numbers, dots and underscores",
})
@Length(3, 32)
username: string;
@Expose()
lastName: string;
@Expose()
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
@MinLength(8)
password: string;
@Expose()
isAdmin: boolean;
@Expose()
totpVerified: boolean;
from(partial: Partial<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
}
fromList(partial: Partial<UserDTO>[]) {
return partial.map((part) =>
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
);
}
}

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