Compare commits

...

111 Commits

Author SHA1 Message Date
Elias Schneider
c9f1be2faf release: 0.18.0 2023-09-21 16:24:07 +02:00
Elias Schneider
57be6945f2 chore(ci/cd): cache Docker build 2023-09-21 16:09:23 +02:00
Elias Schneider
82abe52ea5 chore(translations): update translations via Crowdin (#253)
* New translations en-us.ts (German)

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

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

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

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

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

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

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

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

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

* yes

* Update Dockerfile

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

---------

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

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

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

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

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

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

* change apt-get to apk

* chore: remove unnecessary packages from Dockerfile

* chore: remove unnecessary `chown`

---------

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

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

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Danish)

* New translations en-US.ts (German)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Russian)

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

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

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

* New translations en-US.ts (French)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (German)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Russian)

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

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

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

* Formatted

---------

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

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

* New translations en-US.ts (Russian)

* New translations en-US.ts (German)

* New translations en-US.ts (Russian)

* New translations en-US.ts (Finnish)

* New translations en-US.ts (Russian)
2023-08-01 12:50:41 +02:00
Elias Schneider
7304b54125 release: 0.17.4 2023-08-01 12:37:47 +02:00
Elias Schneider
ea0d5216e8 fix: redirection to localhost:3000 2023-08-01 12:35:37 +02:00
Elias Schneider
62deb6c152 release: 0.17.3 2023-07-31 16:38:58 +02:00
Elias Schneider
9ba2b4c82c fix: logo doesn't get loaded correctly 2023-07-31 16:38:29 +02:00
Elias Schneider
a47d080657 fix: share expiration never doesn't work if using another language than English 2023-07-31 16:34:24 +02:00
Elias Schneider
72a52eb33f release: 0.17.2 2023-07-31 15:37:12 +02:00
Elias Schneider
c9a2a469c6 fix: ECONNREFUSED with Docker ipv6 enabled 2023-07-31 15:37:04 +02:00
Elias Schneider
b534129194 chore(translations): remove Thai 2023-07-31 08:56:22 +02:00
Elias Schneider
0beebfd779 chore(translation): add Russian 2023-07-31 08:55:17 +02:00
Elias Schneider
2ed5ecc1ea release: 0.17.1 2023-07-30 22:34:33 +02:00
Elias Schneider
9bb05158c5 chore: update deps 2023-07-30 22:34:10 +02:00
Elias Schneider
36230371fd chore: update translations via Crowdin (#216)
* New translations en-US.ts (Finnish)

* New translations en-US.ts (Finnish)

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

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

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

* New translations en-US.ts (French)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Danish)

* New translations en-US.ts (German)

* New translations en-US.ts (Portuguese)

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

* New translations en-US.ts (Thai)

* New translations en-US.ts (French)

* New translations en-US.ts (French)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Portuguese)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (German)

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

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

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Danish)

* New translations en-US.ts (German)

* New translations en-US.ts (Portuguese)

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

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

* New translations en-US.ts (German)

* New translations en-US.ts (French)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Danish)

* New translations en-US.ts (German)

* New translations en-US.ts (Portuguese)

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

* New translations en-US.ts (Thai)

* New translations en-US.ts (Spanish)

* New translations en-US.ts (Spanish)

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

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

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

* New translations en.ts (French)

* New translations en.ts (French)

* New translations en.ts (German)

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

* finish Simplified Chinese trans in zh-CN.ts

* fix type error at line:270

---------

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

* New translations en.ts (French)

* New translations en.ts (French)

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

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

* New translations en.ts (Thai)

* New translations en.ts (French)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Danish)

* New translations en.ts (German)

* New translations en.ts (Portuguese)

* New translations en.ts (Chinese Simplified)

* New translations en.ts (Thai)

* New translations en.ts (French)

* New translations en.ts (Spanish)

* New translations en.ts (Spanish)

* New translations en.ts (German)

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

* Added some more translations

* Working on translating even more pages

* More translations

* Added test default locale retrieval

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

* add more translations

* improve title syntax

* add more translations

* translate admin config page

* translated error messages

* add language selecter

* minor fixes

* improve language handling

* add upcoming languages

* add `crowdin.yml`

* run formatter

---------

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

* Ran format

---------

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

* Ran format

* Adding copy icon to the reverse share list

* Remove console.log

* Ran format

* Ran format in backend

* fix: copy to clipboard spelling

* Open the share in another window

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

* Add clickable link to reverse share's shares

* Ran format

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

* fix: set link default value to random

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

* Apply suggestions from code review

---------

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

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

---------

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

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

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

* Add clickable link to reverse share's shares

* Ran format

* Apply suggestions from code review

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

* fix: set link default value to random

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

* Apply suggestions from code review

---------

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

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

---------

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

* Adding other informations and disk usage

* Adding description, disk usage

* Add case if the expiration is never

* Adding file size and better UI

* UI changes to Information Modal

* Adding description to the My Shares page

* Ran format

* Remove string type

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

* Remove string type check

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

* Remove string type conversion

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

* Variable name changes

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

* Remove color

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

* Requested changes made

* Ran format

* Adding MediaQuery

---------

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

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

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

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

* done

* migrate

* edit seed

* removed comments

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

* update shareRecipientsMessage message

* chore: remove `luxon`

* fix: grammatically incorrect `shareRecipientsMessage` message

* changed to defaultValue and value instead

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

* fix: update default value if default value changes

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

* refactor: merge two migrations into one

* fix value check empty

---------

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

* docs: improve stand-alone upgrade guide

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

View File

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

View File

@@ -1,4 +1,4 @@
name: Create Docker Image name: Build and Push Docker Image
on: on:
release: release:
@@ -10,15 +10,25 @@ jobs:
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: login to docker registry
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin - name: Login to Docker registry
- name: Build the image uses: docker/login-action@v2
run: | with:
docker buildx build --push \ username: ${{ secrets.DOCKER_USERNAME }}
--tag stonith404/pingvin-share:latest \ password: ${{ secrets.DOCKER_PASSWORD }}
--tag stonith404/pingvin-share:${{ github.ref_name }} \
--platform linux/amd64,linux/arm64 . - name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-error.log*
# env file # env file
.env .env
!/backend/prisma/.env
# vercel # vercel
.vercel .vercel

View File

@@ -1,3 +1,169 @@
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
### Features
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
### Bug Fixes
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
### Features
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
### Bug Fixes
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
### Bug Fixes
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
### Bug Fixes
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
### Bug Fixes
* `ECONNREFUSED` with Docker ipv6 enabled ([c9a2a46](https://github.com/stonith404/pingvin-share/commit/c9a2a469c67d3c3cd08179b44e2bf82208f05177))
## [0.17.1](https://github.com/stonith404/pingvin-share/compare/v0.17.0...v0.17.1) (2023-07-30)
### Bug Fixes
* rename pt-PT.ts to pt-BR.ts ([2584bb0](https://github.com/stonith404/pingvin-share/commit/2584bb0d48c761940eafc03d5cd98d47e7a5b0ae))
## [0.17.0](https://github.com/stonith404/pingvin-share/compare/v0.16.1...v0.17.0) (2023-07-23)
### Features
* ability to define zip compression level ([7827b68](https://github.com/stonith404/pingvin-share/commit/7827b687fa022e86a2643e7a1951af8c7e80608c))
* add note to language picker ([7f0c31c](https://github.com/stonith404/pingvin-share/commit/7f0c31c2e09b3ee9aae6c3dfb54fac2f2b1dfe23))
* add share url alias `/s` ([231a2e9](https://github.com/stonith404/pingvin-share/commit/231a2e95b9734cf4704454e1945698753dbb378b))
* localization ([#196](https://github.com/stonith404/pingvin-share/issues/196)) ([b9f6e3b](https://github.com/stonith404/pingvin-share/commit/b9f6e3bd08dcfc050048fba582b35958bc7b6184))
* update default value of `maxSize` from `1073741824` to `1000000000` ([389dc87](https://github.com/stonith404/pingvin-share/commit/389dc87cac775d916d0cff9b71d3c5ff90bfe916))
### Bug Fixes
* confusion between GB and GiB ([5816b39](https://github.com/stonith404/pingvin-share/commit/5816b39fc6ef6fe6b7cf8e7925aa297561f5b796))
* mistakes in English translations ([70b425b](https://github.com/stonith404/pingvin-share/commit/70b425b3807be79a3b518cc478996c71dffcf986))
* wrong layout if button text is too long in modals ([f4c88ae](https://github.com/stonith404/pingvin-share/commit/f4c88aeb0823c2c18535c25fcf8e16afa8b53a56))
### [0.16.1](https://github.com/stonith404/pingvin-share/compare/v0.16.0...v0.16.1) (2023-07-10)
### Features
* Adding reverse share ability to copy the link ([#191](https://github.com/stonith404/pingvin-share/issues/191)) ([7574eb3](https://github.com/stonith404/pingvin-share/commit/7574eb3191f21aadd64f436e9e7c78d3e3973a07)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
* Adding reverse shares' shares a clickable link ([#190](https://github.com/stonith404/pingvin-share/issues/190)) ([0276294](https://github.com/stonith404/pingvin-share/commit/0276294f5219a7edcc762bc52391b6720cfd741d))
### Bug Fixes
* set link default value to random ([#192](https://github.com/stonith404/pingvin-share/issues/192)) ([a1ea7c0](https://github.com/stonith404/pingvin-share/commit/a1ea7c026594a54eafd52f764eecbf06e1bb4d4e)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
## [0.16.0](https://github.com/stonith404/pingvin-share/compare/v0.15.0...v0.16.0) (2023-07-09)
### Features
* Adding more informations on My Shares page (table and modal) ([#174](https://github.com/stonith404/pingvin-share/issues/174)) ([1466240](https://github.com/stonith404/pingvin-share/commit/14662404614f15bc25384d924d8cb0458ab06cd8))
* Adding the possibility of copying the link by clicking text and icons ([#171](https://github.com/stonith404/pingvin-share/issues/171)) ([348852c](https://github.com/stonith404/pingvin-share/commit/348852cfa4275f5c642669b43697f83c35333044))
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
### Features
* add env variables for port, database url and data dir ([98c0de7](https://github.com/stonith404/pingvin-share/commit/98c0de78e8a73e3e5bf0928226cfb8a024b566a1))
* add healthcheck endpoint ([5132d17](https://github.com/stonith404/pingvin-share/commit/5132d177b8ab4e00a7e701e9956222fa2352d42c))
* allow to configure clamav with environment variables ([1df5c71](https://github.com/stonith404/pingvin-share/commit/1df5c7123e4ca8695f4f1b7d49f46cdf147fb920))
* configure ports, db url and api url with env variables ([e5071cb](https://github.com/stonith404/pingvin-share/commit/e5071cba1204093197b72e18d024b484e72e360a))
### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07)
### Bug Fixes
* boolean config variables can't be set to false ([39a7451](https://github.com/stonith404/pingvin-share/commit/39a74510c1f00466acaead39f7bee003b3db60d7))
## [0.14.0](https://github.com/stonith404/pingvin-share/compare/v0.13.1...v0.14.0) (2023-04-01)
### Features
* **share, config:** more variables, placeholder and reset default ([#132](https://github.com/stonith404/pingvin-share/issues/132)) ([beece56](https://github.com/stonith404/pingvin-share/commit/beece56327da141c222fd9f5259697df6db9347a))
### Bug Fixes
* bool config variable can't be changed ([0e5c673](https://github.com/stonith404/pingvin-share/commit/0e5c67327092e4751208e559a2b0d5ee2b91b6e3))
### [0.13.1](https://github.com/stonith404/pingvin-share/compare/v0.13.0...v0.13.1) (2023-03-14)
### Bug Fixes
* empty file can't be uploaded in chrome ([9f2097e](https://github.com/stonith404/pingvin-share/commit/9f2097e788dfb79c2f95085025934c3134a3eb38))
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
### Features
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
### Bug Fixes
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
### Bug Fixes
* 48px icon does not update ([753dbe8](https://github.com/stonith404/pingvin-share/commit/753dbe83b770814115a2576c7a50e1bac9dc8ce1))
## [0.12.0](https://github.com/stonith404/pingvin-share/compare/v0.11.1...v0.12.0) (2023-03-10)
### Features
* ability to change logo in frontend ([8403d7e](https://github.com/stonith404/pingvin-share/commit/8403d7e14ded801c3842a9b3fd87c3f6824c519e))
### Bug Fixes
* crypto is not defined ([8f71fd3](https://github.com/stonith404/pingvin-share/commit/8f71fd343506506532c1a24a4c66a16b1021705f))
* home page shown even if disabled ([3ad6b03](https://github.com/stonith404/pingvin-share/commit/3ad6b03b6bd80168870049582683077b689fa548))
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05) ### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)

View File

@@ -1,3 +1,7 @@
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
---
# Contributing # Contributing
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more. We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.

View File

@@ -1,37 +1,45 @@
# Using node slim because prisma ORM needs libc for ARM builds # Stage 1: Frontend dependencies
FROM node:20-alpine AS frontend-dependencies
# Stage 1: on frontend dependency change
FROM node:18-slim AS frontend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 2: on frontend change # Stage 2: Build frontend
FROM node:18-slim AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /opt/app WORKDIR /opt/app
COPY ./frontend . COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build RUN npm run build
# Stage 3: on backend dependency change # Stage 3: Backend dependencies
FROM node:18-slim AS backend-dependencies FROM node:20-alpine AS backend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 4:on backend change # Stage 4: Build backend
FROM node:18-slim AS backend-builder FROM node:20-alpine AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app WORKDIR /opt/app
COPY ./backend . COPY ./backend .
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
RUN npx prisma generate RUN npx prisma generate
RUN npm run build && npm prune --production RUN npm run build && npm prune --production
# Stage 5: Final image # Stage 5: Final image
FROM node:18-slim AS runner FROM node:20-alpine AS runner
ENV NODE_ENV=docker ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y openssl
# Alpine specific dependencies
RUN apk update --no-cache
RUN apk upgrade --no-cache
RUN apk add --no-cache curl
# Set user and group IDs for the node user
ARG UID=1000
ARG GID=1000
RUN deluser node
RUN adduser -u $UID -g $GID node -D
USER node
WORKDIR /opt/app/frontend WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public COPY --from=frontend-builder /opt/app/public ./public
@@ -46,5 +54,12 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
COPY --from=backend-builder /opt/app/package.json ./ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app WORKDIR /opt/app
EXPOSE 3000 EXPOSE 3000
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod
# Add a health check to ensure the container is healthy
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
# Application startup
# HOSTNAME=0.0.0.0 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed
CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod

View File

@@ -1,5 +1,11 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div> # <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
---
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer. Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
## ✨ Features ## ✨ Features
@@ -16,7 +22,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
- [Demo](https://pingvin-share.dev.eliasschneider.com) - [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA) - [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/> <img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ Setup ## ⌨️ Setup
@@ -33,7 +39,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
Required tools: Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 14 - [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background - [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
@@ -88,27 +94,65 @@ docker compose up -d
#### Stand-alone #### Stand-alone
1. Remove the running app 1. Stop the running app
``` ```bash
pm2 delete pingvin-share-backend pingvin-share-frontend pm2 stop pingvin-share-backend pingvin-share-frontend
``` ```
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step. 2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
### Custom branding ```bash
cd pingvin-share
#### Name # Checkout the latest version
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
You can change the name of the app by visiting the admin configuration page and changing the `App Name`. # Start the backend
cd backend
npm run build
pm2 restart pingvin-share-backend
#### Logo # Start the frontend
cd ../frontend
npm run build
pm2 restart pingvin-share-frontend
```
You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images: ### Configuration
- `logo.png` - The logo in the header and home page You can customize Pingvin Share by going to the configuration page in your admin dashboard.
- `favicon.png` - The favicon
- `opengraph.png` - The image used for sharing on social media #### Environment variables
- `icons/*` - The icons used for the PWA
For installation specific configuration, you can use environment variables. The following variables are available:
##### Backend
| Variable | Default Value | Description |
| ---------------- | -------------------------------------------------- | -------------------------------------- |
| `PORT` | `8080` | The port on which the backend listens. |
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
##### Frontend
| Variable | Default Value | Description |
| --------- | ----------------------- | ---------------------------------------- |
| `PORT` | `3000` | The port on which the frontend listens. |
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
## 🖤 Contribute ## 🖤 Contribute
You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started. ### Translations
You can help to translate Pingvin Share into your language.
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
### Project
You're very welcome to contribute to Pingvin Share! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.

1
backend/.prettierignore Normal file
View File

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

View File

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

5119
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

2
backend/prisma/.env Normal file
View File

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

View File

@@ -0,0 +1,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 { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:../data/pingvin-share.db" url = env("DATABASE_URL")
} }
model User { model User {
@@ -131,15 +131,15 @@ model ShareSecurity {
model Config { model Config {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
name String name String
category String category String
type String type String
value String defaultValue String @default("")
description String value String?
obscured Boolean @default(false) obscured Boolean @default(false)
secret Boolean @default(true) secret Boolean @default(true)
locked Boolean @default(false) locked Boolean @default(false)
order Int order Int
@@id([name, category]) @@id([name, category])
} }

View File

@@ -4,150 +4,118 @@ import * as crypto from "crypto";
const configVariables: ConfigVariables = { const configVariables: ConfigVariables = {
internal: { internal: {
jwtSecret: { jwtSecret: {
description: "Long random string used to sign JWT tokens",
type: "string", type: "string",
value: crypto.randomBytes(256).toString("base64"), defaultValue: crypto.randomBytes(256).toString("base64"),
locked: true, locked: true,
}, },
}, },
general: { general: {
appName: { appName: {
description: "Name of the application",
type: "string", type: "string",
value: "Pingvin Share", defaultValue: "Pingvin Share",
secret: false, secret: false,
}, },
appUrl: { appUrl: {
description: "On which URL Pingvin Share is available",
type: "string", type: "string",
value: "http://localhost:3000", defaultValue: "http://localhost:3000",
secret: false, secret: false,
}, },
showHomePage: { showHomePage: {
description: "Whether to show the home page",
type: "boolean", type: "boolean",
value: "true", defaultValue: "true",
secret: false, secret: false,
}, },
}, },
share: { share: {
allowRegistration: { allowRegistration: {
description: "Whether registration is allowed",
type: "boolean", type: "boolean",
value: "true", defaultValue: "true",
secret: false, secret: false,
}, },
allowUnauthenticatedShares: { allowUnauthenticatedShares: {
description: "Whether unauthorized users can create shares",
type: "boolean", type: "boolean",
value: "false", defaultValue: "false",
secret: false, secret: false,
}, },
maxSize: { maxSize: {
description: "Maximum share size in bytes",
type: "number", type: "number",
value: "1073741824", defaultValue: "1000000000",
secret: false, secret: false,
}, },
zipCompressionLevel: {
type: "number",
defaultValue: "9",
},
}, },
email: { email: {
enableShareEmailRecipients: { enableShareEmailRecipients: {
description:
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
type: "boolean", type: "boolean",
value: "false", defaultValue: "false",
secret: false, secret: false,
}, },
shareRecipientsSubject: { shareRecipientsSubject: {
description:
"Subject of the email which gets sent to the share recipients.",
type: "string", type: "string",
value: "Files shared with you", defaultValue: "Files shared with you",
}, },
shareRecipientsMessage: { shareRecipientsMessage: {
description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text", type: "text",
value: defaultValue:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧", "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: { reverseShareSubject: {
description:
"Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string", type: "string",
value: "Reverse share link used", defaultValue: "Reverse share link used",
}, },
reverseShareMessage: { reverseShareMessage: {
description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text", type: "text",
value: defaultValue:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧", "Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
}, },
resetPasswordSubject: { resetPasswordSubject: {
description:
"Subject of the email which gets sent when a user requests a password reset.",
type: "string", type: "string",
value: "Pingvin Share password reset", defaultValue: "Pingvin Share password reset",
}, },
resetPasswordMessage: { resetPasswordMessage: {
description:
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
type: "text", type: "text",
value: defaultValue:
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧", "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: { inviteSubject: {
description:
"Subject of the email which gets sent when an admin invites an user.",
type: "string", type: "string",
value: "Pingvin Share invite", defaultValue: "Pingvin Share invite",
}, },
inviteMessage: { inviteMessage: {
description:
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
type: "text", type: "text",
value: defaultValue:
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧", "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: { smtp: {
enabled: { enabled: {
description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "boolean", type: "boolean",
value: "false", defaultValue: "false",
secret: false, secret: false,
}, },
host: { host: {
description: "Host of the SMTP server",
type: "string", type: "string",
value: "", defaultValue: "",
}, },
port: { port: {
description: "Port of the SMTP server",
type: "number", type: "number",
value: "0", defaultValue: "0",
}, },
email: { email: {
description: "Email address which the emails get sent from",
type: "string", type: "string",
value: "", defaultValue: "",
}, },
username: { username: {
description: "Username of the SMTP server",
type: "string", type: "string",
value: "", defaultValue: "",
}, },
password: { password: {
description: "Password of the SMTP server",
type: "string", type: "string",
value: "", defaultValue: "",
obscured: true, obscured: true,
}, },
}, },
@@ -162,7 +130,15 @@ type ConfigVariables = {
}; };
}; };
const prisma = new PrismaClient(); const prisma = new PrismaClient({
datasources: {
db: {
url:
process.env.DATABASE_URL ||
"file:../data/pingvin-share.db?connection_limit=1",
},
},
});
async function seedConfigVariables() { async function seedConfigVariables() {
for (const [category, configVariablesOfCategory] of Object.entries( for (const [category, configVariablesOfCategory] of Object.entries(

View File

@@ -33,14 +33,14 @@ export class AuthController {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private authTotpService: AuthTotpService, private authTotpService: AuthTotpService,
private config: ConfigService private config: ConfigService,
) {} ) {}
@Post("signUp") @Post("signUp")
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
async signUp( async signUp(
@Body() dto: AuthRegisterDTO, @Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
if (!this.config.get("share.allowRegistration")) if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
@@ -50,7 +50,7 @@ export class AuthController {
response = this.addTokensToResponse( response = this.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
return result; return result;
@@ -61,7 +61,7 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
async signIn( async signIn(
@Body() dto: AuthSignInDTO, @Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
const result = await this.authService.signIn(dto); const result = await this.authService.signIn(dto);
@@ -69,7 +69,7 @@ export class AuthController {
response = this.addTokensToResponse( response = this.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
} }
@@ -81,14 +81,14 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
async signInTotp( async signInTotp(
@Body() dto: AuthSignInTotpDTO, @Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
const result = await this.authTotpService.signInTotp(dto); const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse( response = this.addTokensToResponse(
response, response,
result.refreshToken, result.refreshToken,
result.accessToken result.accessToken,
); );
return new TokenDTO().from(result); return new TokenDTO().from(result);
@@ -113,12 +113,12 @@ export class AuthController {
async updatePassword( async updatePassword(
@GetUser() user: User, @GetUser() user: User,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() dto: UpdatePasswordDTO @Body() dto: UpdatePasswordDTO,
) { ) {
const result = await this.authService.updatePassword( const result = await this.authService.updatePassword(
user, user,
dto.oldPassword, dto.oldPassword,
dto.password dto.password,
); );
response = this.addTokensToResponse(response, result.refreshToken); response = this.addTokensToResponse(response, result.refreshToken);
@@ -129,12 +129,12 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
async refreshAccessToken( async refreshAccessToken(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
if (!request.cookies.refresh_token) throw new UnauthorizedException(); if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken( const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token request.cookies.refresh_token,
); );
response = this.addTokensToResponse(response, undefined, accessToken); response = this.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken }); return new TokenDTO().from({ accessToken });
@@ -143,7 +143,7 @@ export class AuthController {
@Post("signOut") @Post("signOut")
async signOut( async signOut(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response,
) { ) {
await this.authService.signOut(request.cookies.access_token); await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 }); response.cookie("access_token", "accessToken", { maxAge: -1 });
@@ -176,7 +176,7 @@ export class AuthController {
private addTokensToResponse( private addTokensToResponse(
response: Response, response: Response,
refreshToken?: string, refreshToken?: string,
accessToken?: string accessToken?: string,
) { ) {
if (accessToken) if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" }); response.cookie("access_token", accessToken, { sameSite: "lax" });

View File

@@ -6,7 +6,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2"; import * as argon from "argon2";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
@@ -21,7 +21,7 @@ export class AuthService {
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService, private config: ConfigService,
private emailService: EmailService private emailService: EmailService,
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
@@ -39,7 +39,7 @@ export class AuthService {
}); });
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id user.id,
); );
const accessToken = await this.createAccessToken(user, refreshTokenId); const accessToken = await this.createAccessToken(user, refreshTokenId);
@@ -49,7 +49,7 @@ export class AuthService {
if (e.code == "P2002") { if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0]; const duplicatedField: string = e.meta.target[0];
throw new BadRequestException( throw new BadRequestException(
`A user with this ${duplicatedField} already exists` `A user with this ${duplicatedField} already exists`,
); );
} }
} }
@@ -78,7 +78,7 @@ export class AuthService {
} }
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id user.id,
); );
const accessToken = await this.createAccessToken(user, refreshTokenId); const accessToken = await this.createAccessToken(user, refreshTokenId);
@@ -158,7 +158,7 @@ export class AuthService {
{ {
expiresIn: "15min", expiresIn: "15min",
secret: this.config.get("internal.jwtSecret"), secret: this.config.get("internal.jwtSecret"),
} },
); );
} }
@@ -189,7 +189,7 @@ export class AuthService {
return this.createAccessToken( return this.createAccessToken(
refreshTokenMetaData.user, refreshTokenMetaData.user,
refreshTokenMetaData.id refreshTokenMetaData.id,
); );
} }

View File

@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
import * as argon from "argon2"; import * as argon from "argon2";
import { authenticator, totp } from "otplib"; import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg"; import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
export class AuthTotpService { export class AuthTotpService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private authService: AuthService private authService: AuthService,
private config: ConfigService,
) {} ) {}
async signInTotp(dto: AuthSignInTotpDTO) { async signInTotp(dto: AuthSignInTotpDTO) {
@@ -42,7 +44,7 @@ export class AuthTotpService {
throw new UnauthorizedException("Invalid login token"); throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date()) if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired"); throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code // Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({ const { totpSecret } = await this.prisma.user.findUnique({
@@ -70,7 +72,7 @@ export class AuthTotpService {
await this.authService.createRefreshToken(user.id); await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken( const accessToken = await this.authService.createAccessToken(
user, user,
refreshTokenId refreshTokenId,
); );
return { accessToken, refreshToken }; return { accessToken, refreshToken };
@@ -95,8 +97,8 @@ export class AuthTotpService {
const otpURL = totp.keyuri( const otpURL = totp.keyuri(
user.username || user.email, user.username || user.email,
"pingvin-share", this.config.get("general.appName"),
secret secret,
); );
await this.prisma.user.update({ await this.prisma.user.update({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,20 +11,22 @@ import { PrismaService } from "src/prisma/prisma.service";
export class ConfigService { export class ConfigService {
constructor( constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[], @Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService private prisma: PrismaService,
) {} ) {}
get(key: `${string}.${string}`): any { get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter( const configVariable = this.configVariables.filter(
(variable) => `${variable.category}.${variable.name}` == key (variable) => `${variable.category}.${variable.name}` == key,
)[0]; )[0];
if (!configVariable) throw new Error(`Config variable ${key} not found`); if (!configVariable) throw new Error(`Config variable ${key} not found`);
if (configVariable.type == "number") return parseInt(configVariable.value); const value = configVariable.value ?? configVariable.defaultValue;
if (configVariable.type == "boolean") return configVariable.value == "true";
if (configVariable.type == "number") return parseInt(value);
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text") if (configVariable.type == "string" || configVariable.type == "text")
return configVariable.value; return value;
} }
async getByCategory(category: string) { async getByCategory(category: string) {
@@ -35,8 +37,9 @@ export class ConfigService {
return configVariables.map((variable) => { return configVariables.map((variable) => {
return { return {
key: `${variable.category}.${variable.name}`,
...variable, ...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
}; };
}); });
} }
@@ -48,8 +51,9 @@ export class ConfigService {
return configVariables.map((variable) => { return configVariables.map((variable) => {
return { return {
key: `${variable.category}.${variable.name}`,
...variable, ...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
}; };
}); });
} }
@@ -77,13 +81,15 @@ export class ConfigService {
if (!configVariable || configVariable.locked) if (!configVariable || configVariable.locked)
throw new NotFoundException("Config variable not found"); throw new NotFoundException("Config variable not found");
if ( if (value === "") {
value = null;
} else if (
typeof value != configVariable.type && typeof value != configVariable.type &&
typeof value == "string" && typeof value == "string" &&
configVariable.type != "text" configVariable.type != "text"
) { ) {
throw new BadRequestException( throw new BadRequestException(
`Config variable must be of type ${configVariable.type}` `Config variable must be of type ${configVariable.type}`,
); );
} }
@@ -94,7 +100,7 @@ export class ConfigService {
name: key.split(".")[1], name: key.split(".")[1],
}, },
}, },
data: { value: value.toString() }, data: { value: value === null ? null : value.toString() },
}); });
this.configVariables = await this.prisma.config.findMany(); this.configVariables = await this.prisma.config.findMany();

View File

@@ -9,10 +9,10 @@ export class AdminConfigDTO extends ConfigDTO {
secret: boolean; secret: boolean;
@Expose() @Expose()
updatedAt: Date; defaultValue: string;
@Expose() @Expose()
description: string; updatedAt: Date;
@Expose() @Expose()
obscured: boolean; obscured: boolean;
@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
fromList(partial: Partial<AdminConfigDTO>[]) { fromList(partial: Partial<AdminConfigDTO>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }) plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
); );
} }
} }

View File

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

View File

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

View File

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

@@ -1,11 +1,17 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common"; import {
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as moment from "moment";
import * as nodemailer from "nodemailer"; import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() { getTransporter() {
if (!this.config.get("smtp.enabled")) if (!this.config.get("smtp.enabled"))
@@ -22,92 +28,106 @@ export class EmailService {
}); });
} }
async sendMailToShareRecepients( 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, recipientEmail: string,
shareId: string, shareId: string,
creator?: User creator?: User,
description?: string,
expiration?: Date,
) { ) {
if (!this.config.get("email.enableShareEmailRecipients")) if (!this.config.get("email.enableShareEmailRecipients"))
throw new InternalServerErrorException("Email service disabled"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.shareRecipientsSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.shareRecipientsSubject"),
text: this.config
.get("email.shareRecipientsMessage") .get("email.shareRecipientsMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone") .replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl), .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) { async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.reverseShareSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.reverseShareSubject"),
text: this.config
.get("email.reverseShareMessage") .get("email.reverseShareMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl),
}); );
} }
async sendResetPasswordEmail(recipientEmail: string, token: string) { async sendResetPasswordEmail(recipientEmail: string, token: string) {
const resetPasswordUrl = `${this.config.get( const resetPasswordUrl = `${this.config.get(
"general.appUrl" "general.appUrl",
)}/auth/resetPassword/${token}`; )}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.resetPasswordSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.resetPasswordSubject"),
text: this.config
.get("email.resetPasswordMessage") .get("email.resetPasswordMessage")
.replaceAll("\\n", "\n")
.replaceAll("{url}", resetPasswordUrl), .replaceAll("{url}", resetPasswordUrl),
}); );
} }
async sendInviteEmail(recipientEmail: string, password: string) { async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`; const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.inviteSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.inviteSubject"),
text: this.config
.get("email.inviteMessage") .get("email.inviteMessage")
.replaceAll("{url}", loginUrl) .replaceAll("{url}", loginUrl)
.replaceAll("{password}", password), .replaceAll("{password}", password),
}); );
} }
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
try { await this.getTransporter()
await this.getTransporter().sendMail({ .sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get( from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email" "smtp.email",
)}>`, )}>`,
to: recipientEmail, to: recipientEmail,
subject: "Test email", subject: "Test email",
text: "This is a test email", text: "This is a test email",
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException(e.message);
}); });
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e.message);
}
} }
} }

View File

@@ -28,17 +28,18 @@ export class FileController {
@Query() query: any, @Query() query: any,
@Body() body: string, @Body() body: string,
@Param("shareId") shareId: string @Param("shareId") shareId: string,
) { ) {
const { id, name, chunkIndex, totalChunks } = query; const { id, name, chunkIndex, totalChunks } = query;
const data = body.toString().split(",")[1]; // Data can be empty if the file is empty
const data = body.toString().split(",")[1] ?? "";
return await this.fileService.create( return await this.fileService.create(
data, data,
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) }, { index: parseInt(chunkIndex), total: parseInt(totalChunks) },
{ id, name }, { id, name },
shareId shareId,
); );
} }
@@ -46,12 +47,12 @@ export class FileController {
@UseGuards(FileSecurityGuard) @UseGuards(FileSecurityGuard)
async getZip( async getZip(
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string @Param("shareId") shareId: string,
) { ) {
const zip = this.fileService.getZip(shareId); const zip = this.fileService.getZip(shareId);
res.set({ res.set({
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`), "Content-Disposition": contentDisposition(`${shareId}.zip`),
}); });
return new StreamableFile(zip); return new StreamableFile(zip);
@@ -63,7 +64,7 @@ export class FileController {
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string, @Param("shareId") shareId: string,
@Param("fileId") fileId: string, @Param("fileId") fileId: string,
@Query("download") download = "true" @Query("download") download = "true",
) { ) {
const file = await this.fileService.get(shareId, fileId); const file = await this.fileService.get(shareId, fileId);

View File

@@ -11,20 +11,21 @@ import * as fs from "fs";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class FileService { export class FileService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private config: ConfigService private config: ConfigService,
) {} ) {}
async create( async create(
data: string, data: string,
chunk: { index: number; total: number }, chunk: { index: number; total: number },
file: { id?: string; name: string }, file: { id?: string; name: string },
shareId: string shareId: string,
) { ) {
if (!file.id) file.id = crypto.randomUUID(); if (!file.id) file.id = crypto.randomUUID();
@@ -39,7 +40,7 @@ export class FileService {
let diskFileSize: number; let diskFileSize: number;
try { try {
diskFileSize = fs.statSync( diskFileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk` `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
).size; ).size;
} catch { } catch {
diskFileSize = 0; diskFileSize = 0;
@@ -61,7 +62,7 @@ export class FileService {
// Check if share size limit is exceeded // Check if share size limit is exceeded
const fileSizeSum = share.files.reduce( const fileSizeSum = share.files.reduce(
(n, { size }) => n + parseInt(size), (n, { size }) => n + parseInt(size),
0 0,
); );
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength; const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
@@ -73,23 +74,23 @@ export class FileService {
) { ) {
throw new HttpException( throw new HttpException(
"Max share size exceeded", "Max share size exceeded",
HttpStatus.PAYLOAD_TOO_LARGE HttpStatus.PAYLOAD_TOO_LARGE,
); );
} }
fs.appendFileSync( fs.appendFileSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer buffer,
); );
const isLastChunk = chunk.index == chunk.total - 1; const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) { if (isLastChunk) {
fs.renameSync( fs.renameSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`,
); );
const fileSize = fs.statSync( const fileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`,
).size; ).size;
await this.prisma.file.create({ await this.prisma.file.create({
data: { data: {
@@ -111,9 +112,7 @@ export class FileService {
if (!fileMetaData) throw new NotFoundException("File not found"); if (!fileMetaData) throw new NotFoundException("File not found");
const file = fs.createReadStream( const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
`./data/uploads/shares/${shareId}/${fileId}`
);
return { return {
metaData: { metaData: {
@@ -126,13 +125,13 @@ export class FileService {
} }
async deleteAllFiles(shareId: string) { async deleteAllFiles(shareId: string) {
await fs.promises.rm(`./data/uploads/shares/${shareId}`, { await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true, recursive: true,
force: true, force: true,
}); });
} }
getZip(shareId: string) { getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
} }
} }

View File

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

View File

@@ -1,17 +1,20 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service"; import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class JobsService { export class JobsService {
private readonly logger = new Logger(JobsService.name);
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private reverseShareService: ReverseShareService, private reverseShareService: ReverseShareService,
private fileService: FileService private fileService: FileService,
) {} ) {}
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -34,8 +37,9 @@ export class JobsService {
await this.fileService.deleteAllFiles(expiredShare.id); await this.fileService.deleteAllFiles(expiredShare.id);
} }
if (expiredShares.length > 0) if (expiredShares.length > 0) {
console.log(`job: deleted ${expiredShares.length} expired shares`); this.logger.log(`Deleted ${expiredShares.length} expired shares`);
}
} }
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -50,10 +54,11 @@ export class JobsService {
await this.reverseShareService.remove(expiredReverseShare.id); await this.reverseShareService.remove(expiredReverseShare.id);
} }
if (expiredReverseShares.length > 0) if (expiredReverseShares.length > 0) {
console.log( this.logger.log(
`job: deleted ${expiredReverseShares.length} expired reverse shares` `Deleted ${expiredReverseShares.length} expired reverse shares`,
); );
}
} }
@Cron("0 0 * * *") @Cron("0 0 * * *")
@@ -61,31 +66,31 @@ export class JobsService {
let filesDeleted = 0; let filesDeleted = 0;
const shareDirectories = fs const shareDirectories = fs
.readdirSync("./data/uploads/shares", { withFileTypes: true }) .readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory()) .filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name); .map((dirent) => dirent.name);
for (const shareDirectory of shareDirectories) { for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs const temporaryFiles = fs
.readdirSync(`./data/uploads/shares/${shareDirectory}`) .readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk")); .filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) { for (const file of temporaryFiles) {
const stats = fs.statSync( const stats = fs.statSync(
`./data/uploads/shares/${shareDirectory}/${file}` `${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
); );
const isOlderThanOneDay = moment(stats.mtime) const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day") .add(1, "day")
.isBefore(moment()); .isBefore(moment());
if (isOlderThanOneDay) { if (isOlderThanOneDay) {
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`); fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
filesDeleted++; filesDeleted++;
} }
} }
} }
console.log(`job: deleted ${filesDeleted} temporary files`); this.logger.log(`Deleted ${filesDeleted} temporary files`);
} }
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -107,7 +112,8 @@ export class JobsService {
const deletedTokensCount = const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount; refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0) if (deletedTokensCount > 0) {
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`); this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
}
} }
} }

View File

@@ -6,6 +6,7 @@ import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser"; import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -16,7 +17,9 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
recursive: true,
});
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
@@ -30,6 +33,6 @@ async function bootstrap() {
SwaggerModule.setup("api/swagger", app, document); SwaggerModule.setup("api/swagger", app, document);
} }
await app.listen(8080); await app.listen(parseInt(process.env.PORT) || 8080);
} }
bootstrap(); bootstrap();

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export class ReverseShareService {
constructor( constructor(
private config: ConfigService, private config: ConfigService,
private prisma: PrismaService, private prisma: PrismaService,
private fileService: FileService private fileService: FileService,
) {} ) {}
async create(data: CreateReverseShareDTO, creatorId: string) { async create(data: CreateReverseShareDTO, creatorId: string) {
@@ -19,8 +19,8 @@ export class ReverseShareService {
.add( .add(
data.shareExpiration.split("-")[0], data.shareExpiration.split("-")[0],
data.shareExpiration.split( data.shareExpiration.split(
"-" "-",
)[1] as moment.unitOfTime.DurationConstructor )[1] as moment.unitOfTime.DurationConstructor,
) )
.toDate(); .toDate();
@@ -28,7 +28,7 @@ export class ReverseShareService {
if (globalMaxShareSize < data.maxShareSize) if (globalMaxShareSize < data.maxShareSize)
throw new BadRequestException( throw new BadRequestException(
`Max share size can't be greater than ${globalMaxShareSize} bytes.` `Max share size can't be greater than ${globalMaxShareSize} bytes.`,
); );
const reverseShare = await this.prisma.reverseShare.create({ const reverseShare = await this.prisma.reverseShare.create({

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { ReverseShareService } from "src/reverseShare/reverseShare.service";
export class CreateShareGuard extends JwtGuard { export class CreateShareGuard extends JwtGuard {
constructor( constructor(
configService: ConfigService, configService: ConfigService,
private reverseShareService: ReverseShareService private reverseShareService: ReverseShareService,
) { ) {
super(configService); super(configService);
} }
@@ -21,7 +21,7 @@ export class CreateShareGuard extends JwtGuard {
if (!reverseShareTokenId) return false; if (!reverseShareTokenId) return false;
const isReverseShareTokenValid = await this.reverseShareService.isValid( const isReverseShareTokenValid = await this.reverseShareService.isValid(
reverseShareTokenId reverseShareTokenId,
); );
return isReverseShareTokenValid; return isReverseShareTokenValid;

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export class ShareController {
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getMyShares(@GetUser() user: User) { async getMyShares(@GetUser() user: User) {
return new MyShareDTO().fromList( return new MyShareDTO().fromList(
await this.shareService.getSharesByUser(user.id) await this.shareService.getSharesByUser(user.id),
); );
} }
@@ -54,11 +54,11 @@ export class ShareController {
async create( async create(
@Body() body: CreateShareDTO, @Body() body: CreateShareDTO,
@Req() request: Request, @Req() request: Request,
@GetUser() user: User @GetUser() user: User,
) { ) {
const { reverse_share_token } = request.cookies; const { reverse_share_token } = request.cookies;
return new ShareDTO().from( return new ShareDTO().from(
await this.shareService.create(body, user, reverse_share_token) await this.shareService.create(body, user, reverse_share_token),
); );
} }
@@ -74,7 +74,7 @@ export class ShareController {
async complete(@Param("id") id: string, @Req() request: Request) { async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies; const { reverse_share_token } = request.cookies;
return new ShareDTO().from( return new ShareDTO().from(
await this.shareService.complete(id, reverse_share_token) await this.shareService.complete(id, reverse_share_token),
); );
} }
@@ -91,7 +91,7 @@ export class ShareController {
async getShareToken( async getShareToken(
@Param("id") id: string, @Param("id") id: string,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() body: SharePasswordDto @Body() body: SharePasswordDto,
) { ) {
const token = await this.shareService.getShareToken(id, body.password); const token = await this.shareService.getShareToken(id, body.password);
response.cookie(`share_${id}_token`, token, { response.cookie(`share_${id}_token`, token, {

View File

@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service"; import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable() @Injectable()
@@ -27,7 +28,7 @@ export class ShareService {
private config: ConfigService, private config: ConfigService,
private jwtService: JwtService, private jwtService: JwtService,
private reverseShareService: ReverseShareService, private reverseShareService: ReverseShareService,
private clamScanService: ClamScanService private clamScanService: ClamScanService,
) {} ) {}
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) { async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
@@ -45,7 +46,7 @@ export class ShareService {
// If share is created by a reverse share token override the expiration date // If share is created by a reverse share token override the expiration date
const reverseShare = await this.reverseShareService.getByToken( const reverseShare = await this.reverseShareService.getByToken(
reverseShareToken reverseShareToken,
); );
if (reverseShare) { if (reverseShare) {
expirationDate = reverseShare.shareExpiration; expirationDate = reverseShare.shareExpiration;
@@ -56,8 +57,8 @@ export class ShareService {
.add( .add(
share.expiration.split("-")[0], share.expiration.split("-")[0],
share.expiration.split( share.expiration.split(
"-" "-",
)[1] as moment.unitOfTime.DurationConstructor )[1] as moment.unitOfTime.DurationConstructor,
) )
.toDate(); .toDate();
} else { } else {
@@ -65,7 +66,7 @@ export class ShareService {
} }
} }
fs.mkdirSync(`./data/uploads/shares/${share.id}`, { fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
recursive: true, recursive: true,
}); });
@@ -99,11 +100,11 @@ export class ShareService {
} }
async createZip(shareId: string) { async createZip(shareId: string) {
const path = `./data/uploads/shares/${shareId}`; const path = `${SHARE_DIRECTORY}/${shareId}`;
const files = await this.prisma.file.findMany({ where: { shareId } }); const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", { const archive = archiver("zip", {
zlib: { level: 9 }, zlib: { level: this.config.get("share.zipCompressionLevel") },
}); });
const writeStream = fs.createWriteStream(`${path}/archive.zip`); const writeStream = fs.createWriteStream(`${path}/archive.zip`);
@@ -133,21 +134,23 @@ export class ShareService {
if (share.files.length == 0) if (share.files.length == 0)
throw new BadRequestException( throw new BadRequestException(
"You need at least on file in your share to complete it." "You need at least on file in your share to complete it.",
); );
// Asynchronously create a zip of all files // Asynchronously create a zip of all files
if (share.files.length > 1) if (share.files.length > 1)
this.createZip(id).then(() => this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }) this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
); );
// Send email for each recepient // Send email for each recipient
for (const recepient of share.recipients) { for (const recipient of share.recipients) {
await this.emailService.sendMailToShareRecepients( await this.emailService.sendMailToShareRecipients(
recepient.email, recipient.email,
share.id, share.id,
share.creator share.creator,
share.description,
share.expiration,
); );
} }
@@ -158,12 +161,12 @@ export class ShareService {
) { ) {
await this.emailService.sendMailToReverseShareCreator( await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email, share.reverseShare.creator.email,
share.id share.id,
); );
} }
// Check if any file is malicious with ClamAV // Check if any file is malicious with ClamAV
this.clamScanService.checkAndRemove(share.id); void this.clamScanService.checkAndRemove(share.id);
if (share.reverseShare) { if (share.reverseShare) {
await this.prisma.reverseShare.update({ await this.prisma.reverseShare.update({
@@ -172,7 +175,7 @@ export class ShareService {
}); });
} }
return await this.prisma.share.update({ return this.prisma.share.update({
where: { id }, where: { id },
data: { uploadLocked: true }, data: { uploadLocked: true },
}); });
@@ -192,17 +195,15 @@ export class ShareService {
orderBy: { orderBy: {
expiration: "desc", expiration: "desc",
}, },
include: { recipients: true }, include: { recipients: true, files: true },
}); });
const sharesWithEmailRecipients = shares.map((share) => { return shares.map((share) => {
return { return {
...share, ...share,
recipients: share.recipients.map((recipients) => recipients.email), recipients: share.recipients.map((recipients) => recipients.email),
}; };
}); });
return sharesWithEmailRecipients;
} }
async get(id: string): Promise<any> { async get(id: string): Promise<any> {
@@ -222,7 +223,7 @@ export class ShareService {
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
return { return {
...share, ...share,
hasPassword: share.security?.password ? true : false, hasPassword: !!share.security?.password,
}; };
} }
@@ -278,13 +279,13 @@ export class ShareService {
share?.security?.password && share?.security?.password &&
!(await argon.verify(share.security.password, password)) !(await argon.verify(share.security.password, password))
) { ) {
throw new ForbiddenException("Wrong password"); throw new ForbiddenException("Wrong password", "wrong_password");
} }
if (share.security?.maxViews && share.security.maxViews <= share.views) { if (share.security?.maxViews && share.security.maxViews <= share.views) {
throw new ForbiddenException( throw new ForbiddenException(
"Maximum views exceeded", "Maximum views exceeded",
"share_max_views_exceeded" "share_max_views_exceeded",
); );
} }
@@ -304,7 +305,7 @@ export class ShareService {
{ {
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("internal.jwtSecret"), secret: this.config.get("internal.jwtSecret"),
} },
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2"; import * as argon from "argon2";
import * as crypto from "crypto";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
@@ -10,7 +11,7 @@ import { UpdateUserDto } from "./dto/updateUser.dto";
export class UserSevice { export class UserSevice {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private emailService: EmailService private emailService: EmailService,
) {} ) {}
async list() { async list() {
@@ -28,7 +29,7 @@ export class UserSevice {
if (!dto.password) { if (!dto.password) {
const randomPassword = crypto.randomUUID(); const randomPassword = crypto.randomUUID();
hash = await argon.hash(randomPassword); hash = await argon.hash(randomPassword);
this.emailService.sendInviteEmail(dto.email, randomPassword); await this.emailService.sendInviteEmail(dto.email, randomPassword);
} else { } else {
hash = await argon.hash(dto.password); hash = await argon.hash(dto.password);
} }
@@ -45,7 +46,7 @@ export class UserSevice {
if (e.code == "P2002") { if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0]; const duplicatedField: string = e.meta.target[0];
throw new BadRequestException( throw new BadRequestException(
`A user with this ${duplicatedField} already exists` `A user with this ${duplicatedField} already exists`,
); );
} }
} }
@@ -65,7 +66,7 @@ export class UserSevice {
if (e.code == "P2002") { if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0]; const duplicatedField: string = e.meta.target[0];
throw new BadRequestException( throw new BadRequestException(
`A user with this ${duplicatedField} already exists` `A user with this ${duplicatedField} already exists`,
); );
} }
} }

4
crowdin.yml Normal file
View File

@@ -0,0 +1,4 @@
files:
- source: /frontend/src/i18n/translations/en-US.ts
translation: /%original_path%/%locale%.ts
pull_request_title: "chore(translations): update translations via Crowdin"

95
docs/CONTRIBUTING.es.md Normal file
View File

@@ -0,0 +1,95 @@
_Leer esto en otro idioma: [Inglés](/CONTRIBUTING.md), [Español](/docs/CONTRIBUTING.es.md), [Chino Simplificado](/docs/CONTRIBUTING.zh-cn.md)_
---
# Contribuyendo
¡Nos ❤️ encantaría que contribuyas a Pingvin Share y nos ayudes a hacerlo mejor! Todas las contribuciones son bienvenidas, incluyendo problemas, sugerencias, _pull requests_ y más.
## Para comenzar
Si encontraste un error, tienes una sugerencia o algo más, simplemente crea un problema (issue) en GitHub y nos pondremos en contacto contigo 😊.
## Para hacer una Pull Request
Antes de enviar la pull request para su revisión, asegúrate de que:
- El nombre de la pull request sigue las [especificaciones de Commits Convencionales](https://www.conventionalcommits.org/):
`<tipo>[ámbito opcional]: <descripción>`
ejemplo:
```
feat(share): agregar protección con contraseña
```
Donde `tipo` puede ser:
- **feat** - es una nueva función
- **doc** - cambios solo en la documentación
- **fix** - una corrección de error
- **refactor** - cambios en el código que no solucionan un error ni agregan una función
- Tu pull requests tiene una descripción detallada.
- Ejecutaste `npm run format` para formatear el código.
<details>
<summary>¿No sabes como crear una pull request? Aprende cómo crear una pull request</summary>
1. Crea un fork del repositorio haciendo clic en el botón `Fork` en el repositorio de Pingvin Share.
2. Clona tu fork en tu máquina con `git clone`.
```
$ git clone https://github.com/[your_username]/pingvin-share
```
3. Trabajar - hacer commit - repetir
4. Haz un `push` de tus cambios a GitHub.
```
$ git push origin [nombre_de_tu_nueva_rama]
```
5. Envía tus cambios para su revisión. Si vas a tu repositorio en GitHub, verás un botón `Comparar y crear pull requests`. Haz clic en ese botón.
6. Inicia una Pull Request
7. Ahora envía la pull requests y haz clic en `Crear pull requests`
8. Espera a que alguien revise tu solicitud y apruebe o rechace tus cambios. Puedes ver los comentarios en la página de la solicitud en GitHub.
</details>
## Instalación del proyecto
Pingvin Share consiste de un frontend y un backend.
### Backend
El backend está hecho con [Nest.js](https://nestjs.com) y usa Typescript.
#### Instalación
1. Abrimos la carpeta `backend`
2. Instalamos las dependencias con `npm install`
3. Haz un `push` del esquema de la base de datos a la base de datos ejecutando `npx prisma db push`
4. Rellena la base de datos ejecutando `npx prisma db seed`
5. Inicia el backend con `npm run dev`
### Frontend
El frontend está hecho con [Next.js](https://nextjs.org) y usa Typescript.
#### Instalación
1. Primero inicia el backend
2. Abre la carpeta `frontend`
3. Instala las dependencias con `npm install`
4. Inicia el frontend con `npm run dev`
¡Ya está todo listo!
### Testing
Por el momento, solo tenemos pruebas para el backend. Para ejecutar estas pruebas, debes ejecutar el comando `npm run test:system` en la carpeta del backend.

View File

@@ -0,0 +1,97 @@
_选择合适的语言阅读: [西班牙语](/docs/CONTRIBUTING.es.md), [英语](/CONTRIBUTING.md), [简体中文](/docs/CONTRIBUTING.zh-cn.md)_
---
# 提交贡献
我们非常感谢你 ❤️ 为 Pingvin Share 提交贡献使其变得更棒! 欢迎任何形式的贡献,包括 issues, 建议, PRs 和其他形式
## 小小的开始
你找到了一个 bug有新特性建议或者其他提议请在 GitHub 建立一个 issue 以便我和你联络 😊
## 提交一个 Pull Request
在你提交 PR 前请确保
- PR 的名字遵守 [Conventional Commits specification](https://www.conventionalcommits.org):
`<type>[optional scope]: <description>`
例如:
```
feat(share): add password protection
```
`TYPE` 可以是:
- **feat** - 这是一个新特性 feature
- **doc** - 仅仅改变了文档部分 documentation
- **fix** - 修复了一个 bug
- **refactor** - 更新了代码,但是并非出于增加新特性 feature 或修复 bug 的目的
- 请在 PR 中附详细的解释说明
- 使用 `npm run format` 格式化你的代码
<details>
<summary>不知道怎么发起一个 PR 点开了解怎么发起一个 PR </summary>
1. 点击 Pingvin Share 仓库的 `Fork` 按钮,复制一份你的仓库
2. 通过 `git clone` 将你的仓库克隆到本地
```
$ git clone https://github.com/[你的用户名]/pingvin-share
```
3. 进行你的修改 - 提交 commit 你的修改 - 重复直到完成
4. 将你的修改提交到 GitHub
```
$ git push origin [你的新分支的名字]
```
5. 提交你的代码以便代码审查
如果你进入你 fork 的 Github 仓库,你会看到一个 `Compare & pull request` 按钮,点击该按钮
6. 发起一个 PR
7. 点击 `Create pull request` 来提交你的 PR
8. 等待代码审查,通过或以某些原因拒绝
</details>
## 配置开发项目
Pingvin Share 包括前端和后端部分
### 后端
后端使用 [Nest.js](https://nestjs.com) 建立,使用 Typescript
#### 搭建
1. 打开 `backend` 文件夹
2. 使用 `npm install` 安装依赖
3. 通过 `npx prisma db push` 配置数据库结构
4. 通过 `npx prisma db seed` 初始化数据库数据
5. 通过 `npm run dev` 启动后端
### 前端
后端使用 [Next.js](https://nextjs.org) 建立,使用 Typescript
#### 搭建
1. 首先启动后端
2. 打开 `frontend` 文件夹
3. 通过 `npm install` 安装依赖
4. 通过 `npm run dev` 启动前端
开发项目配置完成
### 测试
目前阶段我们只有后端的系统测试,在 `backend` 文件夹运行 `npm run test:system` 来执行系统测试

128
docs/README.es.md Normal file
View File

@@ -0,0 +1,128 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
---
Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alternativa a WeTransfer.
## ✨ Características
- Compartir archivos utilizando un enlace
- Tamaño de archivo ilimitado (unicamente restringido por el espacio en disco)
- Establecer una fecha de caducidad para los recursos compartidos
- Uso compartido seguro con límites de visitantes y contraseñas
- Destinatarios de correo electrónico
- Integración con ClamAV para escaneos de seguridad
## 🐧 Conoce Pingvin Share
- [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Reseña por 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"/>
## ⌨️ Instalación
> Nota: Pingvin Share está en sus primeras etapas y puede contener errores.
### Instalación con Docker (recomendada)
1. Descarge el archivo `docker-compose.yml`
2. Ejecute `docker-compose up -d`
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
### Instalación autónoma
Herramientas requeridas:
- [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) para ejecutar Pingvin Share en segundo plano
```bash
git clone https://github.com/stonith404/pingvin-share
cd pingvin-share
# Consultar la última versión
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Iniciar el backend
cd backend
npm install
npm run build
pm2 start --name="pingvin-share-backend" npm -- run prod
# Iniciar el frontend
cd ../frontend
npm install
npm run build
pm2 start --name="pingvin-share-frontend" npm -- run start
```
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
### Integraciones
#### ClamAV (Unicamente con Docker)
ClamAV se utiliza para escanear los recursos compartidos en busca de archivos maliciosos y eliminarlos si los encuentra.
1. Añade el contenedor ClamAV al stack de Docker Compose (ver `docker-compose.yml`) e inicie el contenedor.
2. Docker esperará a que ClamAV se inicie antes de iniciar Pingvin Share. Esto puede tardar uno o dos minutos.
3. Los registros de Pingvin Share ahora deberían decir "ClamAV está activo".
Por favor, ten en cuenta que ClamAV necesita muchos [recursos](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
### Recursos adicionales
- [Instalación en Synology NAS (Inglés)](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
### Actualizar a una nueva versión
Dado que Pingvin Share se encuentra en una fase inicial, consulte las notas de la versión para conocer los cambios de última hora antes de actualizar.
#### Docker
```bash
docker compose pull
docker compose up -d
```
#### Instalación autónoma
1. Deten la aplicación en ejecución
```bash
pm2 stop pingvin-share-backend pingvin-share-frontend
```
2. Repite los pasos de la [guía de instalación](#instalación-autonoma) excepto el paso de `git clone`.
```bash
cd pingvin-share
# Consultar la última versión
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Iniciar el backend
cd backend
npm run build
pm2 restart pingvin-share-backend
# Iniciar frontend
cd ../frontend
npm run build
pm2 restart pingvin-share-frontend
```
### Marca personalizada
Puedes cambiar el nombre y el logotipo de la aplicación visitando la página de configuración de administrador.
## 🖤 Contribuye
¡Eres bienvenido a contribuir a Pingvin Share! Sige la [guía de contribución](/CONTRIBUTING.md) para empezar.

126
docs/README.zh-cn.md Normal file
View File

@@ -0,0 +1,126 @@
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
---
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
---
Pingvin Share 是一个可自建的文件分享平台,是 WeTransfer 的一个替代品
## ✨ 特性
- 通过可自定义后缀的链接分享文件
- 可自定义任意大小的文件上传限制 (受制于托管所在的硬盘大小)
- 对共享链接设置有效期限
- 对共享链接设置访问次数和访问密码
- 通过邮件自动发送共享链接
- 整合 ClamAV 进行反病毒检查
## 🐧 了解一下 Pingvin Share
- [示例网站](https://pingvin-share.dev.eliasschneider.com)
- [DB Tech 推荐视频](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ 自建指南
> 注意Pingvin Share 仍处于开发阶段并且可能存在 bugs
### Docker 部署 (推荐)
1. 下载 `docker-compose.yml`
2. 运行命令 `docker-compose up -d`
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
### Stand-alone 部署
必须的依赖:
- [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) 用于后台运行 Pingvin Share
```bash
git clone https://github.com/stonith404/pingvin-share
cd pingvin-share
# 获取最新的版本
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# 启动后端 backend
cd backend
npm install
npm run build
pm2 start --name="pingvin-share-backend" npm -- run prod
# 启动前端 frontend
cd ../frontend
npm install
npm run build
pm2 start --name="pingvin-share-frontend" npm -- run start
```
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
### 整合组件
#### ClamAV (仅限 Docker 部署)
扫描上传文件中是否存在可疑文件,如果存在 ClamAV 会自动移除
1. 在 docker-compose 配置中添加 ClamAV 容器 (见 `docker-compose.yml` 注释部分) 并启动容器
2. Docker 会在启动 Pingvin Share 前启动 ClamAV也许会花费 1-2 分钟
3. Pingvin Share 日志中应该有 "ClamAV is active"
请注意 ClamAV 会消耗很多 [系统资源(特别是内存)](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)
### 更多资源
- [群晖 NAS 配置](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
### 升级
因为 Pingvin Share 仍处在开发阶段,在升级前请务必阅读 release notes 避免不可逆的改变
#### Docker 升级
```bash
docker compose pull
docker compose up -d
```
#### Stand-alone 升级
1. 停止正在运行的 app
```bash
pm2 stop pingvin-share-backend pingvin-share-frontend
```
2. 重复 [installation guide](#stand-alone-installation) 中的步骤,除了 `git clone` 这一步
```bash
cd pingvin-share
# 获取最新的版本
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# 启动后端 backend
cd backend
npm run build
pm2 restart pingvin-share-backend
# 启动前端 frontend
cd ../frontend
npm run build
pm2 restart pingvin-share-frontend
```
### 自定义品牌
你可以在管理员配置页面改变网站的名字和 logo
## 🖤 提交贡献
非常欢迎向 Pingvin Share 提交贡献! 请阅读 [contribution guide](/CONTRIBUTING.md) 来提交你的贡献

View File

@@ -1,5 +1,10 @@
{ {
"extends": ["eslint-config-next", "eslint:recommended", "prettier"], "extends": [
"next",
"eslint-config-next",
"eslint:recommended",
"prettier"
],
"plugins": ["react"], "plugins": ["react"],
"rules": { "rules": {
"quotes": ["warn", "double", { "allowTemplateLiterals": true }], "quotes": ["warn", "double", { "allowTemplateLiterals": true }],

1
frontend/.prettierignore Normal file
View File

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

View File

@@ -1,14 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const { version } = require('./package.json'); const { version } = require('./package.json');
const withPWA = require("next-pwa")({ const withPWA = require("next-pwa")({
dest: "public", dest: "public",
disable: process.env.NODE_ENV == "development", disable: process.env.NODE_ENV === "development",
reloadOnOnline: false,
runtimeCaching: [
{
urlPattern: /^https?.*/,
handler: 'NetworkOnly',
},
],
reloadOnOnline: false,
}); });
module.exports = withPWA({ module.exports = withPWA({
output: "standalone", env: { output: "standalone", env: {
VERSION: version, VERSION: version,
}, },
serverRuntimeConfig: {
apiURL: process.env.API_URL ?? 'http://localhost:8080',
},
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,53 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.11.1", "version": "0.18.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write \"src/**/*.ts*\"" "format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.11.1",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.11.0",
"@mantine/core": "^5.10.0", "@mantine/core": "^6.0.17",
"@mantine/dropzone": "^5.10.0", "@mantine/dropzone": "^6.0.17",
"@mantine/form": "^5.10.0", "@mantine/form": "^6.0.17",
"@mantine/hooks": "^5.10.0", "@mantine/hooks": "^6.0.17",
"@mantine/modals": "^5.10.0", "@mantine/modals": "^6.0.17",
"@mantine/next": "^5.10.0", "@mantine/next": "^6.0.17",
"@mantine/notifications": "^5.10.0", "@mantine/notifications": "^6.0.17",
"axios": "^1.2.2", "axios": "^1.4.0",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.11.2", "jose": "^4.14.4",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^13.1.2", "next": "^13.4.12",
"next-cookies": "^2.0.3", "next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5", "next-http-proxy-middleware": "^1.2.5",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"p-limit": "^4.0.0", "p-limit": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.7.1", "react-icons": "^4.10.1",
"yup": "^0.32.11" "react-intl": "^6.4.4",
"sharp": "^0.32.4",
"yup": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/node": "18.11.18", "@types/node": "20.4.5",
"@types/react": "18.0.26", "@types/react": "18.2.17",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.2.7",
"axios": "^1.2.2", "axios": "^1.4.0",
"eslint": "8.31.0", "eslint": "8.46.0",
"eslint-config-next": "^13.1.2", "eslint-config-next": "^13.4.12",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.9.0",
"prettier": "^2.8.2", "prettier": "^3.0.0",
"tar": "^6.1.13", "tar": "^6.1.15",
"typescript": "^4.9.4" "typescript": "^5.1.6"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,36 @@
import { Select } from "@mantine/core";
import { getCookie, setCookie } from "cookies-next";
import { useState } from "react";
import useTranslate from "../../hooks/useTranslate.hook";
import { LOCALES } from "../../i18n/locales";
const LanguagePicker = () => {
const t = useTranslate();
const [selectedLanguage, setSelectedLanguage] = useState(
getCookie("language")?.toString(),
);
const languages = Object.values(LOCALES).map((locale) => ({
value: locale.code,
label: locale.name,
}));
return (
<Select
value={selectedLanguage}
description={t("account.card.language.description")}
onChange={(value) => {
setSelectedLanguage(value ?? "en");
setCookie("language", value, {
sameSite: "lax",
expires: new Date(
new Date().setFullYear(new Date().getFullYear() + 1),
),
});
location.reload();
}}
data={languages}
/>
);
};
export default LanguagePicker;

View File

@@ -9,12 +9,12 @@ import {
import { useColorScheme } from "@mantine/hooks"; import { useColorScheme } from "@mantine/hooks";
import { useState } from "react"; import { useState } from "react";
import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb"; import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb";
import usePreferences from "../../hooks/usePreferences"; import { FormattedMessage } from "react-intl";
import userPreferences from "../../utils/userPreferences.util";
const ThemeSwitcher = () => { const ThemeSwitcher = () => {
const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState( const [colorScheme, setColorScheme] = useState(
preferences.get("colorScheme") userPreferences.get("colorScheme"),
); );
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
@@ -23,10 +23,10 @@ const ThemeSwitcher = () => {
<SegmentedControl <SegmentedControl
value={colorScheme} value={colorScheme}
onChange={(value) => { onChange={(value) => {
preferences.set("colorScheme", value); userPreferences.set("colorScheme", value);
setColorScheme(value); setColorScheme(value);
toggleColorScheme( toggleColorScheme(
value == "system" ? systemColorScheme : (value as ColorScheme) value == "system" ? systemColorScheme : (value as ColorScheme),
); );
}} }}
data={[ data={[
@@ -34,7 +34,9 @@ const ThemeSwitcher = () => {
label: ( label: (
<Center> <Center>
<TbMoon size={16} /> <TbMoon size={16} />
<Box ml={10}>Dark</Box> <Box ml={10}>
<FormattedMessage id="account.theme.dark" />
</Box>
</Center> </Center>
), ),
value: "dark", value: "dark",
@@ -43,7 +45,9 @@ const ThemeSwitcher = () => {
label: ( label: (
<Center> <Center>
<TbSun size={16} /> <TbSun size={16} />
<Box ml={10}>Light</Box> <Box ml={10}>
<FormattedMessage id="account.theme.light" />
</Box>
</Center> </Center>
), ),
value: "light", value: "light",
@@ -52,7 +56,9 @@ const ThemeSwitcher = () => {
label: ( label: (
<Center> <Center>
<TbDeviceLaptop size={16} /> <TbDeviceLaptop size={16} />
<Box ml={10}>System</Box> <Box ml={10}>
<FormattedMessage id="account.theme.system" />
</Box>
</Center> </Center>
), ),
value: "system", value: "system",

View File

@@ -1,19 +1,21 @@
import { import {
Button, Button,
Center, Center,
Col, Group,
Grid,
Image, Image,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useTranslate, {
translateOutsideContext,
} from "../../hooks/useTranslate.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -24,10 +26,11 @@ const showEnableTotpModal = (
qrCode: string; qrCode: string;
secret: string; secret: string;
password: string; password: string;
} },
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Enable TOTP</Title>, title: t("account.modal.totp.title"),
children: ( children: (
<CreateEnableTotpModal options={options} refreshUser={refreshUser} /> <CreateEnableTotpModal options={options} refreshUser={refreshUser} />
), ),
@@ -46,6 +49,7 @@ const CreateEnableTotpModal = ({
refreshUser: () => {}; refreshUser: () => {};
}) => { }) => {
const modals = useModals(); const modals = useModals();
const t = useTranslate();
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
code: yup code: yup
@@ -67,14 +71,19 @@ const CreateEnableTotpModal = ({
<div> <div>
<Center> <Center>
<Stack> <Stack>
<Text>Step 1: Add your authenticator</Text> <Text>
<FormattedMessage id="account.modal.totp.step1" />
</Text>
<Image src={options.qrCode} alt="QR Code" /> <Image src={options.qrCode} alt="QR Code" />
<Center> <Center>
<span>OR</span> <span>
{" "}
<FormattedMessage id="common.text.or" />
</span>
</Center> </Center>
<Tooltip label="Click to copy"> <Tooltip label={t("account.modal.totp.clickToCopy")}>
<Button <Button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(options.secret); navigator.clipboard.writeText(options.secret);
@@ -85,38 +94,42 @@ const CreateEnableTotpModal = ({
</Button> </Button>
</Tooltip> </Tooltip>
<Center> <Center>
<Text fz="xs">Enter manually</Text> <Text fz="xs"></Text>
</Center> </Center>
<Text>Step 2: Validate your code</Text> <Text>
<FormattedMessage id="account.modal.totp.step2" />
</Text>
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
authService authService
.verifyTOTP(values.code, options.password) .verifyTOTP(values.code, options.password)
.then(() => { .then(() => {
toast.success("Successfully enabled TOTP"); toast.success(t("account.notify.totp.enable"));
modals.closeAll(); modals.closeAll();
refreshUser(); refreshUser();
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
})} })}
> >
<Grid align="flex-end"> <Group align="end">
<Col xs={9}> <TextInput
<TextInput style={{ flex: "1" }}
variant="filled" variant="filled"
label="Code" label={t("account.modal.totp.code")}
placeholder="******" placeholder="******"
{...form.getInputProps("code")} {...form.getInputProps("code")}
/> />
</Col>
<Col xs={3}> <Button
<Button variant="outline" type="submit"> style={{ flex: "0 0 auto" }}
Verify variant="outline"
</Button> type="submit"
</Col> >
</Grid> <FormattedMessage id="account.modal.totp.verify" />
</Button>
</Group>
</form> </form>
</Stack> </Stack>
</Center> </Center>

View File

@@ -0,0 +1,22 @@
import { Stack, TextInput } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
const showReverseShareLinkModal = (
modals: ModalsContextProps,
reverseShareToken: string,
appUrl: string,
) => {
const t = translateOutsideContext();
const link = `${appUrl}/upload/${reverseShareToken}`;
return modals.openModal({
title: t("account.reverseShares.modal.reverse-share-link"),
children: (
<Stack align="stretch">
<TextInput variant="filled" value={link} />
</Stack>
),
});
};
export default showReverseShareLinkModal;

View File

@@ -0,0 +1,99 @@
import { Divider, Flex, Progress, Stack, Text } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import { FormattedMessage } from "react-intl";
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
import { FileMetaData } from "../../types/File.type";
import { MyShare } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import CopyTextField from "../upload/CopyTextField";
const showShareInformationsModal = (
modals: ModalsContextProps,
share: MyShare,
appUrl: string,
maxShareSize: number,
) => {
const t = translateOutsideContext();
const link = `${appUrl}/s/${share.id}`;
let shareSize: number = 0;
for (let file of share.files as FileMetaData[])
shareSize += parseInt(file.size);
const formattedShareSize = byteToHumanSizeString(shareSize);
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
const shareSizeProgress = (shareSize / maxShareSize) * 100;
const formattedCreatedAt = moment(share.createdAt).format("LLL");
const formattedExpiration =
moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL");
return modals.openModal({
title: t("account.shares.modal.share-informations"),
children: (
<Stack align="stretch" spacing="md">
<Text size="sm" color="lightgray">
<b>
<FormattedMessage id="account.shares.table.id" />:{" "}
</b>
{share.id}
</Text>
<Text size="sm" color="lightgray">
<b>
<FormattedMessage id="account.shares.table.description" />:{" "}
</b>
{share.description || "No description"}
</Text>
<Text size="sm" color="lightgray">
<b>
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
</b>
{formattedCreatedAt}
</Text>
<Text size="sm" color="lightgray">
<b>
<FormattedMessage id="account.shares.table.expiresAt" />:{" "}
</b>
{formattedExpiration}
</Text>
<Divider />
<CopyTextField link={link} />
<Divider />
<Text size="sm" color="lightgray">
<b>
<FormattedMessage id="account.shares.table.size" />:{" "}
</b>
{formattedShareSize} / {formattedMaxShareSize} (
{shareSizeProgress.toFixed(1)}%)
</Text>
<Flex align="center" justify="center">
{shareSize / maxShareSize < 0.1 && (
<Text size="xs" color="lightgray" style={{ marginRight: "4px" }}>
{formattedShareSize}
</Text>
)}
<Progress
value={shareSizeProgress}
label={shareSize / maxShareSize >= 0.1 ? formattedShareSize : ""}
style={{ width: shareSize / maxShareSize < 0.1 ? "70%" : "80%" }}
size="xl"
radius="xl"
/>
<Text size="xs" color="lightgray" style={{ marginLeft: "4px" }}>
{formattedMaxShareSize}
</Text>
</Flex>
</Stack>
),
});
};
export default showShareInformationsModal;

View File

@@ -1,14 +1,16 @@
import { Stack, TextInput } from "@mantine/core"; import { Stack, TextInput } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
const showShareLinkModal = ( const showShareLinkModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
shareId: string, shareId: string,
appUrl: string appUrl: string,
) => { ) => {
const link = `${appUrl}/share/${shareId}`; const t = translateOutsideContext();
const link = `${appUrl}/s/${shareId}`;
return modals.openModal({ return modals.openModal({
title: "Share link", title: t("account.shares.modal.share-link"),
children: ( children: (
<Stack align="stretch"> <Stack align="stretch">
<TextInput variant="filled" value={link} /> <TextInput variant="filled" value={link} />

View File

@@ -18,10 +18,13 @@ const AdminConfigInput = ({
}) => { }) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
stringValue: configVariable.value, stringValue: configVariable.value ?? configVariable.defaultValue,
textValue: configVariable.value, textValue: configVariable.value ?? configVariable.defaultValue,
numberValue: parseInt(configVariable.value), numberValue: parseInt(
booleanValue: configVariable.value == "true", configVariable.value ?? configVariable.defaultValue,
),
booleanValue:
(configVariable.value ?? configVariable.defaultValue) == "true",
}, },
}); });
@@ -35,29 +38,38 @@ const AdminConfigInput = ({
{configVariable.type == "string" && {configVariable.type == "string" &&
(configVariable.obscured ? ( (configVariable.obscured ? (
<PasswordInput <PasswordInput
style={{ width: "100%" }} style={{
width: "100%",
}}
{...form.getInputProps("stringValue")} {...form.getInputProps("stringValue")}
onChange={(e) => onValueChange(configVariable, e.target.value)} onChange={(e) => onValueChange(configVariable, e.target.value)}
/> />
) : ( ) : (
<TextInput <TextInput
style={{ width: "100%" }} style={{
width: "100%",
}}
{...form.getInputProps("stringValue")} {...form.getInputProps("stringValue")}
placeholder={configVariable.defaultValue}
onChange={(e) => onValueChange(configVariable, e.target.value)} onChange={(e) => onValueChange(configVariable, e.target.value)}
/> />
))} ))}
{configVariable.type == "text" && ( {configVariable.type == "text" && (
<Textarea <Textarea
style={{ width: "100%" }} style={{
width: "100%",
}}
autosize autosize
{...form.getInputProps("textValue")} {...form.getInputProps("textValue")}
placeholder={configVariable.defaultValue}
onChange={(e) => onValueChange(configVariable, e.target.value)} onChange={(e) => onValueChange(configVariable, e.target.value)}
/> />
)} )}
{configVariable.type == "number" && ( {configVariable.type == "number" && (
<NumberInput <NumberInput
{...form.getInputProps("numberValue")} {...form.getInputProps("numberValue")}
placeholder={configVariable.defaultValue}
onChange={(number) => onValueChange(configVariable, number)} onChange={(number) => onValueChange(configVariable, number)}
/> />
)} )}

View File

@@ -9,6 +9,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import Link from "next/link"; import Link from "next/link";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
import { FormattedMessage } from "react-intl";
import useConfig from "../../../hooks/config.hook"; import useConfig from "../../../hooks/config.hook";
import Logo from "../../Logo"; import Logo from "../../Logo";
@@ -42,7 +43,7 @@ const ConfigurationHeader = ({
</Link> </Link>
<MediaQuery smallerThan="sm" styles={{ display: "none" }}> <MediaQuery smallerThan="sm" styles={{ display: "none" }}>
<Button variant="light" component={Link} href="/admin"> <Button variant="light" component={Link} href="/admin">
Go back <FormattedMessage id="common.button.go-back" />
</Button> </Button>
</MediaQuery> </MediaQuery>
</Group> </Group>

View File

@@ -12,6 +12,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb"; import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
const categories = [ const categories = [
{ name: "General", icon: <TbSquare /> }, { name: "General", icon: <TbSquare /> },
@@ -53,7 +54,7 @@ const ConfigurationNavBar = ({
> >
<Navbar.Section> <Navbar.Section>
<Text size="xs" color="dimmed" mb="sm"> <Text size="xs" color="dimmed" mb="sm">
Configuration <FormattedMessage id="admin.config.title" />
</Text> </Text>
<Stack spacing="xs"> <Stack spacing="xs">
{categories.map((category) => ( {categories.map((category) => (
@@ -79,7 +80,11 @@ const ConfigurationNavBar = ({
> >
{category.icon} {category.icon}
</ThemeIcon> </ThemeIcon>
<Text size="sm">{category.name}</Text> <Text size="sm">
<FormattedMessage
id={`admin.config.category.${category.name.toLowerCase()}`}
/>
</Text>
</Group> </Group>
</Box> </Box>
))} ))}
@@ -87,7 +92,7 @@ const ConfigurationNavBar = ({
</Navbar.Section> </Navbar.Section>
<MediaQuery largerThan="sm" styles={{ display: "none" }}> <MediaQuery largerThan="sm" styles={{ display: "none" }}>
<Button mt="xl" variant="light" component={Link} href="/admin"> <Button mt="xl" variant="light" component={Link} href="/admin">
Go back <FormattedMessage id="common.button.go-back" />
</Button> </Button>
</MediaQuery> </MediaQuery>
</Navbar> </Navbar>

View File

@@ -0,0 +1,43 @@
import { Box, FileInput, Group, Stack, Text, Title } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { Dispatch, SetStateAction } from "react";
import { TbUpload } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import useTranslate from "../../../hooks/useTranslate.hook";
const LogoConfigInput = ({
logo,
setLogo,
}: {
logo: File | null;
setLogo: Dispatch<SetStateAction<File | null>>;
}) => {
const isMobile = useMediaQuery("(max-width: 560px)");
const t = useTranslate();
return (
<Group position="apart">
<Stack style={{ maxWidth: isMobile ? "100%" : "40%" }} spacing={0}>
<Title order={6}>
<FormattedMessage id="admin.config.general.logo" />
</Title>
<Text color="dimmed" size="sm" mb="xs">
<FormattedMessage id="admin.config.general.logo.description" />
</Text>
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
<FileInput
clearable
icon={<TbUpload size={14} />}
value={logo}
onChange={(v) => setLogo(v)}
accept=".png"
placeholder={t("admin.config.general.logo.placeholder")}
/>
</Box>
</Group>
);
};
export default LogoConfigInput;

View File

@@ -1,6 +1,7 @@
import { Button, Stack, Text, Textarea } from "@mantine/core"; import { Button, Stack, Text, Textarea } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl";
import useUser from "../../../hooks/user.hook"; import useUser from "../../../hooks/user.hook";
import configService from "../../../services/config.service"; import configService from "../../../services/config.service";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
@@ -32,7 +33,7 @@ const TestEmailButton = ({
<Textarea minRows={4} readOnly value={e.response.data.message} /> <Textarea minRows={4} readOnly value={e.response.data.message} />
</Stack> </Stack>
), ),
}) }),
); );
}; };
@@ -65,7 +66,7 @@ const TestEmailButton = ({
} }
}} }}
> >
Send test email <FormattedMessage id="admin.config.smtp.button.test" />
</Button> </Button>
); );
}; };

View File

@@ -3,6 +3,7 @@ import { useModals } from "@mantine/modals";
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb"; import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
import User from "../../../types/user.type"; import User from "../../../types/user.type";
import showUpdateUserModal from "./showUpdateUserModal"; import showUpdateUserModal from "./showUpdateUserModal";
import { FormattedMessage, useIntl } from "react-intl";
const ManageUserTable = ({ const ManageUserTable = ({
users, users,
@@ -22,9 +23,15 @@ const ManageUserTable = ({
<Table verticalSpacing="sm"> <Table verticalSpacing="sm">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>
<th>Email</th> <FormattedMessage id="admin.users.table.username" />
<th>Admin</th> </th>
<th>
<FormattedMessage id="admin.users.table.email" />
</th>
<th>
<FormattedMessage id="admin.users.table.admin" />
</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>

View File

@@ -5,21 +5,22 @@ import {
Stack, Stack,
Switch, Switch,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useTranslate from "../../../hooks/useTranslate.hook";
import userService from "../../../services/user.service"; import userService from "../../../services/user.service";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showCreateUserModal = ( const showCreateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
smtpEnabled: boolean, smtpEnabled: boolean,
getUsers: () => void getUsers: () => void,
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Create user</Title>, title: "Create user",
children: ( children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} /> <Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
), ),
@@ -35,6 +36,7 @@ const Body = ({
smtpEnabled: boolean; smtpEnabled: boolean;
getUsers: () => void; getUsers: () => void;
}) => { }) => {
const t = useTranslate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
username: "", username: "",
@@ -45,10 +47,15 @@ const Body = ({
}, },
validate: yupResolver( validate: yupResolver(
yup.object().shape({ yup.object().shape({
email: yup.string().email(), email: yup.string().email(t("common.error.invalid-email")),
username: yup.string().min(3), username: yup
password: yup.string().min(8).optional(), .string()
}) .min(3, t("common.error.too-short", { length: 3 })),
password: yup
.string()
.min(8, t("common.error.too-short", { length: 8 }))
.optional(),
}),
), ),
}); });
@@ -66,26 +73,33 @@ const Body = ({
})} })}
> >
<Stack> <Stack>
<TextInput label="Username" {...form.getInputProps("username")} /> <TextInput
<TextInput label="Email" {...form.getInputProps("email")} /> label={t("admin.users.modal.create.username")}
{...form.getInputProps("username")}
/>
<TextInput
label={t("admin.users.modal.create.email")}
{...form.getInputProps("email")}
/>
{smtpEnabled && ( {smtpEnabled && (
<Switch <Switch
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Set password manually" label={t("admin.users.modal.create.manual-password")}
description="If not checked, the user will receive an email with a link to set their password." description={t(
"admin.users.modal.create.manual-password.description",
)}
{...form.getInputProps("setPasswordManually", { {...form.getInputProps("setPasswordManually", {
type: "checkbox", type: "checkbox",
})} })}
/> />
)} )}
{form.values.setPasswordManually || {(form.values.setPasswordManually || !smtpEnabled) && (
(!smtpEnabled && ( <PasswordInput
<PasswordInput label={t("admin.users.modal.create.password")}
label="Password" {...form.getInputProps("password")}
{...form.getInputProps("password")} />
/> )}
))}
<Switch <Switch
styles={{ styles={{
body: { body: {
@@ -95,12 +109,14 @@ const Body = ({
}} }}
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Admin privileges" label={t("admin.users.modal.create.admin")}
description="If checked, the user will be able to access the admin panel." description={t("admin.users.modal.create.admin.description")}
{...form.getInputProps("isAdmin", { type: "checkbox" })} {...form.getInputProps("isAdmin", { type: "checkbox" })}
/> />
<Group position="right"> <Group position="right">
<Button type="submit">Create</Button> <Button type="submit">
<FormattedMessage id="common.button.create" />
</Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>

View File

@@ -6,11 +6,14 @@ import {
Stack, Stack,
Switch, Switch,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import userService from "../../../services/user.service"; import userService from "../../../services/user.service";
import User from "../../../types/user.type"; import User from "../../../types/user.type";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
@@ -18,10 +21,11 @@ import toast from "../../../utils/toast.util";
const showUpdateUserModal = ( const showUpdateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
user: User, user: User,
getUsers: () => void getUsers: () => void,
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Update {user.username}</Title>, title: t("admin.users.edit.update.title", { username: user.username }),
children: <Body user={user} modals={modals} getUsers={getUsers} />, children: <Body user={user} modals={modals} getUsers={getUsers} />,
}); });
}; };
@@ -35,6 +39,8 @@ const Body = ({
user: User; user: User;
getUsers: () => void; getUsers: () => void;
}) => { }) => {
const t = useTranslate();
const accountForm = useForm({ const accountForm = useForm({
initialValues: { initialValues: {
username: user.username, username: user.username,
@@ -43,9 +49,11 @@ const Body = ({
}, },
validate: yupResolver( validate: yupResolver(
yup.object().shape({ yup.object().shape({
email: yup.string().email(), email: yup.string().email(t("common.error.invalid-email")),
username: yup.string().min(3), username: yup
}) .string()
.min(3, t("common.error.too-short", { length: 3 })),
}),
), ),
}); });
@@ -55,8 +63,10 @@ const Body = ({
}, },
validate: yupResolver( validate: yupResolver(
yup.object().shape({ yup.object().shape({
password: yup.string().min(8), password: yup
}) .string()
.min(8, t("common.error.too-short", { length: 8 })),
}),
), ),
}); });
@@ -76,21 +86,26 @@ const Body = ({
> >
<Stack> <Stack>
<TextInput <TextInput
label="Username" label={t("admin.users.table.username")}
{...accountForm.getInputProps("username")} {...accountForm.getInputProps("username")}
/> />
<TextInput label="Email" {...accountForm.getInputProps("email")} /> <TextInput
label={t("admin.users.table.email")}
{...accountForm.getInputProps("email")}
/>
<Switch <Switch
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Admin privileges" label={t("admin.users.edit.update.admin-privileges")}
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })} {...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
/> />
</Stack> </Stack>
</form> </form>
<Accordion> <Accordion>
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword"> <Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
<Accordion.Control px={0}>Change password</Accordion.Control> <Accordion.Control px={0}>
<FormattedMessage id="admin.users.edit.update.change-password.title" />
</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<form <form
onSubmit={passwordForm.onSubmit(async (values) => { onSubmit={passwordForm.onSubmit(async (values) => {
@@ -98,17 +113,21 @@ const Body = ({
.update(user.id, { .update(user.id, {
password: values.password, password: values.password,
}) })
.then(() => toast.success("Password changed successfully")) .then(() =>
toast.success(
t("admin.users.edit.update.notify.password.success"),
),
)
.catch(toast.axiosError); .catch(toast.axiosError);
})} })}
> >
<Stack> <Stack>
<PasswordInput <PasswordInput
label="New password" label={t("admin.users.edit.update.change-password.field")}
{...passwordForm.getInputProps("password")} {...passwordForm.getInputProps("password")}
/> />
<Button variant="light" type="submit"> <Button variant="light" type="submit">
Save new password <FormattedMessage id="admin.users.edit.update.change-password.button" />
</Button> </Button>
</Stack> </Stack>
</form> </form>
@@ -117,7 +136,7 @@ const Body = ({
</Accordion> </Accordion>
<Group position="right"> <Group position="right">
<Button type="submit" form="accountForm"> <Button type="submit" form="accountForm">
Save <FormattedMessage id="common.button.save" />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -15,8 +15,10 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { TbInfoCircle } from "react-icons/tb"; import { TbInfoCircle } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -24,19 +26,18 @@ import toast from "../../utils/toast.util";
const SignInForm = ({ redirectPath }: { redirectPath: string }) => { const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
const config = useConfig(); const config = useConfig();
const router = useRouter(); const router = useRouter();
const t = useTranslate();
const { refreshUser } = useUser(); const { refreshUser } = useUser();
const [showTotp, setShowTotp] = React.useState(false); const [showTotp, setShowTotp] = React.useState(false);
const [loginToken, setLoginToken] = React.useState(""); const [loginToken, setLoginToken] = React.useState("");
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(), emailOrUsername: yup.string().required(t("common.error.field-required")),
password: yup.string().min(8).required(), password: yup
totp: yup.string().when("totpRequired", { .string()
is: true, .min(8, t("common.error.too-short", { length: 8 }))
then: yup.string().min(6).max(6).required(), .required(t("common.error.field-required")),
otherwise: yup.string(),
}),
}); });
const form = useForm({ const form = useForm({
@@ -59,8 +60,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
icon: <TbInfoCircle />, icon: <TbInfoCircle />,
color: "blue", color: "blue",
radius: "md", radius: "md",
title: "Two-factor authentication required", title: t("signIn.notify.totp-required.title"),
message: "Please enter your two-factor authentication code", message: t("signIn.notify.totp-required.description"),
}); });
setLoginToken(response.data["loginToken"]); setLoginToken(response.data["loginToken"]);
} else { } else {
@@ -79,8 +80,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
router.replace(redirectPath); router.replace(redirectPath);
}) })
.catch((error) => { .catch((error) => {
if (error?.response?.data?.message == "Login token expired") { if (error?.response?.data?.error == "share_password_required") {
toast.error("Login token expired"); toast.axiosError(error);
// Refresh the page to start over // Refresh the page to start over
window.location.reload(); window.location.reload();
} }
@@ -93,13 +94,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title order={2} align="center" weight={900}> <Title order={2} align="center" weight={900}>
Welcome back <FormattedMessage id="signin.title" />
</Title> </Title>
{config.get("share.allowRegistration") && ( {config.get("share.allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}> <Text color="dimmed" size="sm" align="center" mt={5}>
You don't have an account yet?{" "} <FormattedMessage id="signin.description" />{" "}
<Anchor component={Link} href={"signUp"} size="sm"> <Anchor component={Link} href={"signUp"} size="sm">
{"Sign up"} <FormattedMessage id="signin.button.signup" />
</Anchor> </Anchor>
</Text> </Text>
)} )}
@@ -112,20 +113,20 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
})} })}
> >
<TextInput <TextInput
label="Email or username" label={t("signin.input.email-or-username")}
placeholder="Your email or username" placeholder={t("signin.input.email-or-username.placeholder")}
{...form.getInputProps("emailOrUsername")} {...form.getInputProps("emailOrUsername")}
/> />
<PasswordInput <PasswordInput
label="Password" label={t("signin.input.password")}
placeholder="Your password" placeholder={t("signin.input.password.placeholder")}
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
{showTotp && ( {showTotp && (
<TextInput <TextInput
variant="filled" variant="filled"
label="Code" label={t("account.modal.totp.code")}
placeholder="******" placeholder="******"
mt="md" mt="md"
{...form.getInputProps("totp")} {...form.getInputProps("totp")}
@@ -134,12 +135,12 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
{config.get("smtp.enabled") && ( {config.get("smtp.enabled") && (
<Group position="right" mt="xs"> <Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs"> <Anchor component={Link} href="/auth/resetPassword" size="xs">
Forgot password? <FormattedMessage id="resetPassword.title" />
</Anchor> </Anchor>
</Group> </Group>
)} )}
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
Sign in <FormattedMessage id="signin.button.submit" />
</Button> </Button>
</form> </form>
</Paper> </Paper>

View File

@@ -11,8 +11,10 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -20,12 +22,19 @@ import toast from "../../utils/toast.util";
const SignUpForm = () => { const SignUpForm = () => {
const config = useConfig(); const config = useConfig();
const router = useRouter(); const router = useRouter();
const t = useTranslate();
const { refreshUser } = useUser(); const { refreshUser } = useUser();
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
email: yup.string().email().required(), email: yup.string().email(t("common.error.invalid-email")).required(),
username: yup.string().min(3).required(), username: yup
password: yup.string().min(8).required(), .string()
.min(3, t("common.error.too-short", { length: 3 }))
.required(t("common.error.field-required")),
password: yup
.string()
.min(8, t("common.error.too-short", { length: 8 }))
.required(t("common.error.field-required")),
}); });
const form = useForm({ const form = useForm({
@@ -54,41 +63,41 @@ const SignUpForm = () => {
return ( return (
<Container size={420} my={40}> <Container size={420} my={40}>
<Title order={2} align="center" weight={900}> <Title order={2} align="center" weight={900}>
Sign up <FormattedMessage id="signup.title" />
</Title> </Title>
{config.get("share.allowRegistration") && ( {config.get("share.allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}> <Text color="dimmed" size="sm" align="center" mt={5}>
You have an account already?{" "} <FormattedMessage id="signup.description" />{" "}
<Anchor component={Link} href={"signIn"} size="sm"> <Anchor component={Link} href={"signIn"} size="sm">
Sign in <FormattedMessage id="signup.button.signin" />
</Anchor> </Anchor>
</Text> </Text>
)} )}
<Paper withBorder shadow="md" p={30} mt={30} radius="md"> <Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form <form
onSubmit={form.onSubmit((values) => onSubmit={form.onSubmit((values) =>
signUp(values.email, values.username, values.password) signUp(values.email, values.username, values.password),
)} )}
> >
<TextInput <TextInput
label="Username" label={t("signup.input.username")}
placeholder="Your username" placeholder={t("signup.input.username.placeholder")}
{...form.getInputProps("username")} {...form.getInputProps("username")}
/> />
<TextInput <TextInput
label="Email" label={t("signup.input.email")}
placeholder="Your email" placeholder={t("signup.input.email.placeholder")}
mt="md" mt="md"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
label="Password" label={t("signin.input.password")}
placeholder="Your password" placeholder={t("signin.input.password.placeholder")}
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
Let's get started <FormattedMessage id="signup.button.submit" />
</Button> </Button>
</form> </form>
</Paper> </Paper>

View File

@@ -0,0 +1,41 @@
import { ActionIcon } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
import { TbChevronDown, TbChevronUp, TbSelector } from "react-icons/tb";
export type TableSort = {
property?: string;
direction: "asc" | "desc";
};
const TableSortIcon = ({
sort,
setSort,
property,
}: {
sort: TableSort;
setSort: Dispatch<SetStateAction<TableSort>>;
property: string;
}) => {
if (sort.property === property) {
return (
<ActionIcon
onClick={() =>
setSort({
property,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
{sort.direction === "asc" ? <TbChevronDown /> : <TbChevronUp />}
</ActionIcon>
);
} else {
return (
<ActionIcon onClick={() => setSort({ property, direction: "asc" })}>
<TbSelector />
</ActionIcon>
);
}
};
export default TableSortIcon;

View File

@@ -3,6 +3,7 @@ import Link from "next/link";
import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb"; import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
import { FormattedMessage, useIntl } from "react-intl";
const ActionAvatar = () => { const ActionAvatar = () => {
const { user } = useUser(); const { user } = useUser();
@@ -16,7 +17,7 @@ const ActionAvatar = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}> <Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
My account <FormattedMessage id="navbar.avatar.account" />
</Menu.Item> </Menu.Item>
{user!.isAdmin && ( {user!.isAdmin && (
<Menu.Item <Menu.Item
@@ -24,7 +25,7 @@ const ActionAvatar = () => {
href="/admin" href="/admin"
icon={<TbSettings size={14} />} icon={<TbSettings size={14} />}
> >
Administration <FormattedMessage id="navbar.avatar.admin" />
</Menu.Item> </Menu.Item>
)} )}
@@ -34,7 +35,7 @@ const ActionAvatar = () => {
}} }}
icon={<TbDoorExit size={14} />} icon={<TbDoorExit size={14} />}
> >
Sign out <FormattedMessage id="navbar.avatar.signout" />
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

@@ -16,6 +16,7 @@ import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import useTranslate from "../../hooks/useTranslate.hook";
import Logo from "../Logo"; import Logo from "../Logo";
import ActionAvatar from "./ActionAvatar"; import ActionAvatar from "./ActionAvatar";
import NavbarShareMenu from "./NavbarShareMenu"; import NavbarShareMenu from "./NavbarShareMenu";
@@ -112,6 +113,7 @@ const Header = () => {
const { user } = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
const config = useConfig(); const config = useConfig();
const t = useTranslate();
const [opened, toggleOpened] = useDisclosure(false); const [opened, toggleOpened] = useDisclosure(false);
@@ -124,7 +126,7 @@ const Header = () => {
const authenticatedLinks: NavLink[] = [ const authenticatedLinks: NavLink[] = [
{ {
link: "/upload", link: "/upload",
label: "Upload", label: t("navbar.upload"),
}, },
{ {
component: <NavbarShareMenu />, component: <NavbarShareMenu />,
@@ -137,27 +139,27 @@ const Header = () => {
let unauthenticatedLinks: NavLink[] = [ let unauthenticatedLinks: NavLink[] = [
{ {
link: "/auth/signIn", link: "/auth/signIn",
label: "Sign in", label: t("navbar.signin"),
}, },
]; ];
if (config.get("share.allowUnauthenticatedShares")) { if (config.get("share.allowUnauthenticatedShares")) {
unauthenticatedLinks.unshift({ unauthenticatedLinks.unshift({
link: "/upload", link: "/upload",
label: "Upload", label: t("navbar.upload"),
}); });
} }
if (config.get("general.showHomePage")) if (config.get("general.showHomePage"))
unauthenticatedLinks.unshift({ unauthenticatedLinks.unshift({
link: "/", link: "/",
label: "Home", label: t("navbar.home"),
}); });
if (config.get("share.allowRegistration")) if (config.get("share.allowRegistration"))
unauthenticatedLinks.push({ unauthenticatedLinks.push({
link: "/auth/signUp", link: "/auth/signUp",
label: "Sign up", label: t("navbar.signup"),
}); });
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();

View File

@@ -1,6 +1,7 @@
import { ActionIcon, Menu } from "@mantine/core"; import { ActionIcon, Menu } from "@mantine/core";
import Link from "next/link"; import Link from "next/link";
import { TbArrowLoopLeft, TbLink } from "react-icons/tb"; import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
const NavbarShareMneu = () => { const NavbarShareMneu = () => {
return ( return (
@@ -12,14 +13,14 @@ const NavbarShareMneu = () => {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item component={Link} href="/account/shares" icon={<TbLink />}> <Menu.Item component={Link} href="/account/shares" icon={<TbLink />}>
My shares <FormattedMessage id="navbar.links.shares" />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
component={Link} component={Link}
href="/account/reverseShares" href="/account/reverseShares"
icon={<TbArrowLoopLeft />} icon={<TbArrowLoopLeft />}
> >
Reverse shares <FormattedMessage id="navbar.links.reverse" />
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>

View File

@@ -1,11 +1,15 @@
import { Button } from "@mantine/core"; import { Button } from "@mantine/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import useTranslate from "../../hooks/useTranslate.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const DownloadAllButton = ({ shareId }: { shareId: string }) => { const DownloadAllButton = ({ shareId }: { shareId: string }) => {
const [isZipReady, setIsZipReady] = useState(false); const [isZipReady, setIsZipReady] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const t = useTranslate();
const downloadAll = async () => { const downloadAll = async () => {
setIsLoading(true); setIsLoading(true);
await shareService await shareService
@@ -39,13 +43,13 @@ const DownloadAllButton = ({ shareId }: { shareId: string }) => {
loading={isLoading} loading={isLoading}
onClick={() => { onClick={() => {
if (!isZipReady) { if (!isZipReady) {
toast.error("The share is preparing. Try again in a few minutes."); toast.error(t("share.notify.download-all-preparing"));
} else { } else {
downloadAll(); downloadAll();
} }
}} }}
> >
Download all <FormattedMessage id="share.button.download-all" />
</Button> </Button>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Box,
Group, Group,
Skeleton, Skeleton,
Stack, Stack,
@@ -8,9 +9,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import mime from "mime-types"; import { Dispatch, SetStateAction, useEffect, useState } from "react";
import Link from "next/link";
import { TbDownload, TbEye, TbLink } from "react-icons/tb"; import { TbDownload, TbEye, TbLink } from "react-icons/tb";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
@@ -18,19 +17,52 @@ import { FileMetaData } from "../../types/File.type";
import { Share } from "../../types/share.type"; import { Share } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
import TableSortIcon, { TableSort } from "../core/SortIcon";
import showFilePreviewModal from "./modals/showFilePreviewModal";
import useTranslate from "../../hooks/useTranslate.hook";
import { FormattedMessage } from "react-intl";
const FileList = ({ const FileList = ({
files, files,
setShare,
share, share,
isLoading, isLoading,
}: { }: {
files?: FileMetaData[]; files?: FileMetaData[];
setShare: Dispatch<SetStateAction<Share | undefined>>;
share: Share; share: Share;
isLoading: boolean; isLoading: boolean;
}) => { }) => {
const clipboard = useClipboard(); const clipboard = useClipboard();
const config = useConfig(); const config = useConfig();
const modals = useModals(); const modals = useModals();
const t = useTranslate();
const [sort, setSort] = useState<TableSort>({
property: undefined,
direction: "desc",
});
const sortFiles = () => {
if (files && sort.property) {
const sortedFiles = files.sort((a: any, b: any) => {
if (sort.direction === "asc") {
return b[sort.property!].localeCompare(a[sort.property!], undefined, {
numeric: true,
});
} else {
return a[sort.property!].localeCompare(b[sort.property!], undefined, {
numeric: true,
});
}
});
setShare({
...share,
files: sortedFiles,
});
}
};
const copyFileLink = (file: FileMetaData) => { const copyFileLink = (file: FileMetaData) => {
const link = `${config.get("general.appUrl")}/api/shares/${ const link = `${config.get("general.appUrl")}/api/shares/${
@@ -39,10 +71,10 @@ const FileList = ({
if (window.isSecureContext) { if (window.isSecureContext) {
clipboard.copy(link); clipboard.copy(link);
toast.success("Your file link was copied to the keyboard."); toast.success(t("common.notify.copied"));
} else { } else {
modals.openModal({ modals.openModal({
title: "File link", title: t("share.modal.file-link"),
children: ( children: (
<Stack align="stretch"> <Stack align="stretch">
<TextInput variant="filled" value={link} /> <TextInput variant="filled" value={link} />
@@ -52,55 +84,70 @@ const FileList = ({
} }
}; };
useEffect(sortFiles, [sort]);
return ( return (
<Table> <Box sx={{ display: "block", overflowX: "auto" }}>
<thead> <Table>
<tr> <thead>
<th>Name</th> <tr>
<th>Size</th> <th>
<th></th> <Group spacing="xs">
</tr> <FormattedMessage id="share.table.name" />
</thead> <TableSortIcon sort={sort} setSort={setSort} property="name" />
<tbody> </Group>
{isLoading </th>
? skeletonRows <th>
: files!.map((file) => ( <Group spacing="xs">
<tr key={file.name}> <FormattedMessage id="share.table.size" />
<td>{file.name}</td> <TableSortIcon sort={sort} setSort={setSort} property="size" />
<td>{byteToHumanSizeString(parseInt(file.size))}</td> </Group>
<td> </th>
<Group position="right"> <th></th>
{shareService.doesFileSupportPreview(file.name) && ( </tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
<td>
<Group position="right">
{shareService.doesFileSupportPreview(file.name) && (
<ActionIcon
onClick={() =>
showFilePreviewModal(share.id, file, modals)
}
size={25}
>
<TbEye />
</ActionIcon>
)}
{!share.hasPassword && (
<ActionIcon
size={25}
onClick={() => copyFileLink(file)}
>
<TbLink />
</ActionIcon>
)}
<ActionIcon <ActionIcon
component={Link}
href={`/share/${share.id}/preview/${
file.id
}?type=${mime.contentType(file.name)}`}
target="_blank"
size={25} size={25}
onClick={async () => {
await shareService.downloadFile(share.id, file.id);
}}
> >
<TbEye /> <TbDownload />
</ActionIcon> </ActionIcon>
)} </Group>
{!share.hasPassword && ( </td>
<ActionIcon size={25} onClick={() => copyFileLink(file)}> </tr>
<TbLink /> ))}
</ActionIcon> </tbody>
)} </Table>
<ActionIcon </Box>
size={25}
onClick={async () => {
await shareService.downloadFile(share.id, file.id);
}}
>
<TbDownload />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
); );
}; };

View File

@@ -0,0 +1,160 @@
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { modals } from "@mantine/modals";
import Link from "next/link";
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import api from "../../services/api.service";
const FilePreviewContext = React.createContext<{
shareId: string;
fileId: string;
mimeType: string;
setIsNotSupported: Dispatch<SetStateAction<boolean>>;
}>({
shareId: "",
fileId: "",
mimeType: "",
setIsNotSupported: () => {},
});
const FilePreview = ({
shareId,
fileId,
mimeType,
}: {
shareId: string;
fileId: string;
mimeType: string;
}) => {
const [isNotSupported, setIsNotSupported] = useState(false);
if (isNotSupported) return <UnSupportedFile />;
return (
<Stack>
<FilePreviewContext.Provider
value={{ shareId, fileId, mimeType, setIsNotSupported }}
>
<FileDecider />
</FilePreviewContext.Provider>
<Button
variant="subtle"
component={Link}
onClick={() => modals.closeAll()}
target="_blank"
href={`/api/shares/${shareId}/files/${fileId}?download=false`}
>
View original file
{/* Add translation? */}
</Button>
</Stack>
);
};
const FileDecider = () => {
const { mimeType, setIsNotSupported } = React.useContext(FilePreviewContext);
if (mimeType == "application/pdf") {
return <PdfPreview />;
} else if (mimeType.startsWith("video/")) {
return <VideoPreview />;
} else if (mimeType.startsWith("image/")) {
return <ImagePreview />;
} else if (mimeType.startsWith("audio/")) {
return <AudioPreview />;
} else if (mimeType.startsWith("text/")) {
return <TextPreview />;
} else {
setIsNotSupported(true);
return null;
}
};
const AudioPreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<audio controls style={{ width: "100%" }}>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
onError={() => setIsNotSupported(true)}
/>
</audio>
</Stack>
</Center>
);
};
const VideoPreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
<video width="100%" controls>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
onError={() => setIsNotSupported(true)}
/>
</video>
);
};
const ImagePreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
alt={`${fileId}_preview`}
width="100%"
onError={() => setIsNotSupported(true)}
/>
);
};
const TextPreview = () => {
const { shareId, fileId } = React.useContext(FilePreviewContext);
const [text, setText] = useState<string | null>(null);
useEffect(() => {
api
.get(`/shares/${shareId}/files/${fileId}?download=false`)
.then((res) => setText(res.data));
}, [shareId, fileId]);
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<Text sx={{ whiteSpace: "pre-wrap" }} size="sm">
{text}
</Text>
</Stack>
</Center>
);
};
const PdfPreview = () => {
const { shareId, fileId } = React.useContext(FilePreviewContext);
if (typeof window !== "undefined") {
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
}
return null;
};
const UnSupportedFile = () => {
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10}>
<Title order={3}>
<FormattedMessage id="share.modal.file-preview.error.not-supported.title" />
</Title>
<Text>
<FormattedMessage id="share.modal.file-preview.error.not-supported.description" />
</Text>
</Stack>
</Center>
);
};
export default FilePreview;

View File

@@ -34,8 +34,10 @@ const FileSizeInput = ({
label={label} label={label}
value={size} value={size}
onChange={(value) => { onChange={(value) => {
setSize(value!); if (value) {
onChange(unitAndSizeToByte(unit, value!)); setSize(value);
onChange(unitAndSizeToByte(unit, value));
}
}} }}
/> />
</Col> </Col>

View File

@@ -1,24 +1,21 @@
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { TbCopy } from "react-icons/tb"; import { FormattedMessage } from "react-intl";
import toast from "../../../utils/toast.util"; import { translateOutsideContext } from "../../../hooks/useTranslate.hook";
import CopyTextField from "../../upload/CopyTextField";
const showCompletedReverseShareModal = ( const showCompletedReverseShareModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
link: string, link: string,
getReverseShares: () => void getReverseShares: () => void,
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: t("account.reverseShares.modal.reverse-share-link"),
<Stack align="stretch" spacing={0}>
<Title order={4}>Reverse share link</Title>
</Stack>
),
children: <Body link={link} getReverseShares={getReverseShares} />, children: <Body link={link} getReverseShares={getReverseShares} />,
}); });
}; };
@@ -30,28 +27,11 @@ const Body = ({
link: string; link: string;
getReverseShares: () => void; getReverseShares: () => void;
}) => { }) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals(); const modals = useModals();
return ( return (
<Stack align="stretch"> <Stack align="stretch">
<TextInput <CopyTextField link={link} />
readOnly
variant="filled"
value={link}
rightSection={
window.isSecureContext && (
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<TbCopy />
</ActionIcon>
)
}
/>
<Button <Button
onClick={() => { onClick={() => {
@@ -59,7 +39,7 @@ const Body = ({
getReverseShares(); getReverseShares();
}} }}
> >
Done <FormattedMessage id="common.button.done" />
</Button> </Button>
</Stack> </Stack>
); );

View File

@@ -8,11 +8,14 @@ import {
Stack, Stack,
Switch, Switch,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import shareService from "../../../services/share.service"; import shareService from "../../../services/share.service";
import { getExpirationPreview } from "../../../utils/date.util"; import { getExpirationPreview } from "../../../utils/date.util";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
@@ -22,10 +25,11 @@ import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
const showCreateReverseShareModal = ( const showCreateReverseShareModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
showSendEmailNotificationOption: boolean, showSendEmailNotificationOption: boolean,
getReverseShares: () => void getReverseShares: () => void,
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Create reverse share</Title>, title: t("account.reverseShares.modal.title"),
children: ( children: (
<Body <Body
showSendEmailNotificationOption={showSendEmailNotificationOption} showSendEmailNotificationOption={showSendEmailNotificationOption}
@@ -43,6 +47,7 @@ const Body = ({
showSendEmailNotificationOption: boolean; showSendEmailNotificationOption: boolean;
}) => { }) => {
const modals = useModals(); const modals = useModals();
const t = useTranslate();
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
@@ -62,7 +67,7 @@ const Body = ({
values.expiration_num + values.expiration_unit, values.expiration_num + values.expiration_unit,
values.maxShareSize, values.maxShareSize,
values.maxUseCount, values.maxUseCount,
values.sendEmailNotification values.sendEmailNotification,
) )
.then(({ link }) => { .then(({ link }) => {
modals.closeAll(); modals.closeAll();
@@ -80,7 +85,7 @@ const Body = ({
max={99999} max={99999}
precision={0} precision={0}
variant="filled" variant="filled"
label="Share expiration" label={t("account.reverseShares.modal.expiration.label")}
{...form.getInputProps("expiration_num")} {...form.getInputProps("expiration_num")}
/> />
</Col> </Col>
@@ -92,27 +97,44 @@ const Body = ({
{ {
value: "-minutes", value: "-minutes",
label: label:
"Minute" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.minute-singular")
: t("upload.modal.expires.minute-plural"),
}, },
{ {
value: "-hours", value: "-hours",
label: label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.hour-singular")
: t("upload.modal.expires.hour-plural"),
}, },
{ {
value: "-days", value: "-days",
label: label:
"Day" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.day-singular")
: t("upload.modal.expires.day-plural"),
}, },
{ {
value: "-weeks", value: "-weeks",
label: label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.week-singular")
: t("upload.modal.expires.week-plural"),
}, },
{ {
value: "-months", value: "-months",
label: label:
"Month" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.month-singular")
: t("upload.modal.expires.month-plural"),
},
{
value: "-years",
label:
form.values.expiration_num == 1
? t("upload.modal.expires.year-singular")
: t("upload.modal.expires.year-plural"),
}, },
]} ]}
/> />
@@ -126,11 +148,17 @@ const Body = ({
color: theme.colors.gray[6], color: theme.colors.gray[6],
})} })}
> >
{getExpirationPreview("reverse share", form)} {getExpirationPreview(
{
expiresOn: t("account.reverseShare.expires-on"),
neverExpires: t("account.reverseShare.never-expires"),
},
form,
)}
</Text> </Text>
</div> </div>
<FileSizeInput <FileSizeInput
label="Max share size" label={t("account.reverseShares.modal.max-size.label")}
value={form.values.maxShareSize} value={form.values.maxShareSize}
onChange={(number) => form.setFieldValue("maxShareSize", number)} onChange={(number) => form.setFieldValue("maxShareSize", number)}
/> />
@@ -139,16 +167,18 @@ const Body = ({
max={1000} max={1000}
precision={0} precision={0}
variant="filled" variant="filled"
label="Max use count" label={t("account.reverseShares.modal.max-use.label")}
description="The maximum number of times this reverse share link can be used" description={t("account.reverseShares.modal.max-use.description")}
{...form.getInputProps("maxUseCount")} {...form.getInputProps("maxUseCount")}
/> />
{showSendEmailNotificationOption && ( {showSendEmailNotificationOption && (
<Switch <Switch
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Send email notification" label={t("account.reverseShares.modal.send-email")}
description="Send an email notification when a share is created with this reverse share link" description={t(
"account.reverseShares.modal.send-email.description",
)}
{...form.getInputProps("sendEmailNotification", { {...form.getInputProps("sendEmailNotification", {
type: "checkbox", type: "checkbox",
})} })}
@@ -156,7 +186,7 @@ const Body = ({
)} )}
<Button mt="md" type="submit"> <Button mt="md" type="submit">
Create <FormattedMessage id="common.button.create" />
</Button> </Button>
</Stack> </Stack>
</form> </form>

View File

@@ -0,0 +1,21 @@
import { ModalsContextProps } from "@mantine/modals/lib/context";
import mime from "mime-types";
import { FileMetaData } from "../../../types/File.type";
import FilePreview from "../FilePreview";
const showFilePreviewModal = (
shareId: string,
file: FileMetaData,
modals: ModalsContextProps,
) => {
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
return modals.openModal({
size: "xl",
title: file.name,
children: (
<FilePreview shareId={shareId} fileId={file.id} mimeType={mimeType} />
),
});
};
export default showFilePreviewModal;

View File

@@ -1,57 +1,60 @@
import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core"; import { Button, PasswordInput, Stack, Text } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react"; import { useState } from "react";
import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../hooks/useTranslate.hook";
const showEnterPasswordModal = ( const showEnterPasswordModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
submitCallback: any submitCallback: (password: string) => Promise<void>,
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: t("share.modal.password.title"),
<>
<Title order={4}>Password required</Title>
<Text size="sm">
This access this share please enter the password for the share.
</Text>
</>
),
children: <Body submitCallback={submitCallback} />, children: <Body submitCallback={submitCallback} />,
}); });
}; };
const Body = ({ submitCallback }: { submitCallback: any }) => { const Body = ({
submitCallback,
}: {
submitCallback: (password: string) => Promise<void>;
}) => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordWrong, setPasswordWrong] = useState(false); const [passwordWrong, setPasswordWrong] = useState(false);
const t = useTranslate();
return ( return (
<> <Stack align="stretch">
<Stack align="stretch"> <Text size="sm">
<PasswordInput <FormattedMessage id="share.modal.password.description" />
variant="filled" </Text>
placeholder="Password"
error={passwordWrong && "Wrong password"} <form
onFocus={() => setPasswordWrong(false)} onSubmit={(e) => {
onChange={(e) => setPassword(e.target.value)} e.preventDefault();
value={password} submitCallback(password);
/> }}
<Button >
onClick={() => <Stack>
submitCallback(password) <PasswordInput
.then((res: any) => res) variant="filled"
.catch((e: any) => { placeholder={t("share.modal.password")}
const error = e.response.data.message; error={passwordWrong && t("share.modal.error.invalid-password")}
if (error == "Wrong password") { onFocus={() => setPasswordWrong(false)}
setPasswordWrong(true); onChange={(e) => setPassword(e.target.value)}
} value={password}
}) />
} <Button type="submit">
> <FormattedMessage id="common.button.submit" />
Submit </Button>
</Button> </Stack>
</Stack> </form>
</> </Stack>
); );
}; };

View File

@@ -1,18 +1,19 @@
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FormattedMessage } from "react-intl";
const showErrorModal = ( const showErrorModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
title: string, title: string,
text: string text: string,
) => { ) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: <Title order={4}>{title}</Title>, title: title,
children: <Body text={text} />, children: <Body text={text} />,
}); });
@@ -31,7 +32,7 @@ const Body = ({ text }: { text: string }) => {
router.back(); router.back();
}} }}
> >
Go back <FormattedMessage id="common.button.go-back" />
</Button> </Button>
</Stack> </Stack>
</> </>

View File

@@ -0,0 +1,51 @@
import { ActionIcon, TextInput } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useRef, useState } from "react";
import { TbCheck, TbCopy } from "react-icons/tb";
import useTranslate from "../../hooks/useTranslate.hook";
import toast from "../../utils/toast.util";
function CopyTextField(props: { link: string }) {
const clipboard = useClipboard({ timeout: 500 });
const t = useTranslate();
const [checkState, setCheckState] = useState(false);
const [textClicked, setTextClicked] = useState(false);
const timerRef = useRef<number | ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const copyLink = () => {
clipboard.copy(props.link);
toast.success(t("common.notify.copied"));
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setCheckState(false);
}, 1500);
setCheckState(true);
};
return (
<TextInput
readOnly
label={t("common.text.link")}
variant="filled"
value={props.link}
onClick={() => {
if (!textClicked) {
copyLink();
setTextClicked(true);
}
}}
rightSection={
window.isSecureContext && (
<ActionIcon onClick={copyLink}>
{checkState ? <TbCheck /> : <TbCopy />}
</ActionIcon>
)
}
/>
);
}
export default CopyTextField;

View File

@@ -1,8 +1,9 @@
import { Button, Center, createStyles, Group, Text } from "@mantine/core"; import { Button, Center, createStyles, Group, Text } from "@mantine/core";
import { Dropzone as MantineDropzone } from "@mantine/dropzone"; import { Dropzone as MantineDropzone } from "@mantine/dropzone";
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; import { ForwardedRef, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb"; import { TbCloudUpload, TbUpload } from "react-icons/tb";
import useConfig from "../../hooks/config.hook"; import { FormattedMessage } from "react-intl";
import useTranslate from "../../hooks/useTranslate.hook";
import { FileUpload } from "../../types/File.type"; import { FileUpload } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -34,15 +35,13 @@ const useStyles = createStyles((theme) => ({
const Dropzone = ({ const Dropzone = ({
isUploading, isUploading,
maxShareSize, maxShareSize,
files, showCreateUploadModalCallback,
setFiles,
}: { }: {
isUploading: boolean; isUploading: boolean;
maxShareSize: number; maxShareSize: number;
files: FileUpload[]; showCreateUploadModalCallback: (files: FileUpload[]) => void;
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => { }) => {
const config = useConfig(); const t = useTranslate();
const { classes } = useStyles(); const { classes } = useStyles();
const openRef = useRef<() => void>(); const openRef = useRef<() => void>();
@@ -54,24 +53,21 @@ const Dropzone = ({
}} }}
disabled={isUploading} disabled={isUploading}
openRef={openRef as ForwardedRef<() => void>} openRef={openRef as ForwardedRef<() => void>}
onDrop={(newFiles: FileUpload[]) => { onDrop={(files: FileUpload[]) => {
const fileSizeSum = [...newFiles, ...files].reduce( const fileSizeSum = files.reduce((n, { size }) => n + size, 0);
(n, { size }) => n + size,
0
);
if (fileSizeSum > maxShareSize) { if (fileSizeSum > maxShareSize) {
toast.error( toast.error(
`Your files exceed the maximum share size of ${byteToHumanSizeString( t("upload.dropzone.notify.file-too-big", {
maxShareSize maxSize: byteToHumanSizeString(maxShareSize),
)}.` })
); );
} else { } else {
newFiles = newFiles.map((newFile) => { files = files.map((newFile) => {
newFile.uploadingProgress = 0; newFile.uploadingProgress = 0;
return newFile; return newFile;
}); });
setFiles([...newFiles, ...files]); showCreateUploadModalCallback(files);
} }
}} }}
className={classes.dropzone} className={classes.dropzone}
@@ -82,12 +78,13 @@ const Dropzone = ({
<TbCloudUpload size={50} /> <TbCloudUpload size={50} />
</Group> </Group>
<Text align="center" weight={700} size="lg" mt="xl"> <Text align="center" weight={700} size="lg" mt="xl">
Upload files <FormattedMessage id="upload.dropzone.title" />
</Text> </Text>
<Text align="center" size="sm" mt="xs" color="dimmed"> <Text align="center" size="sm" mt="xs" color="dimmed">
Drag&apos;n&apos;drop files here to start your share. We can accept <FormattedMessage
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "} id="upload.dropzone.description"
in total. values={{ maxSize: byteToHumanSizeString(maxShareSize) }}
/>
</Text> </Text>
</div> </div>
</MantineDropzone> </MantineDropzone>

View File

@@ -4,6 +4,7 @@ import { TbTrash } from "react-icons/tb";
import { FileUpload } from "../../types/File.type"; import { FileUpload } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
import UploadProgressIndicator from "./UploadProgressIndicator"; import UploadProgressIndicator from "./UploadProgressIndicator";
import { FormattedMessage } from "react-intl";
const FileList = ({ const FileList = ({
files, files,
@@ -41,8 +42,12 @@ const FileList = ({
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>
<th>Size</th> <FormattedMessage id="upload.filelist.name" />
</th>
<th>
<FormattedMessage id="upload.filelist.size" />
</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>

View File

@@ -1,63 +1,40 @@
import { import { Button, Stack, Text } from "@mantine/core";
ActionIcon,
Button,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb"; import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import { Share } from "../../../types/share.type"; import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util"; import CopyTextField from "../CopyTextField";
const showCompletedUploadModal = ( const showCompletedUploadModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
share: Share, share: Share,
appUrl: string appUrl: string,
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: t("upload.modal.completed.share-ready"),
<Stack align="stretch" spacing={0}>
<Title order={4}>Share ready</Title>
</Stack>
),
children: <Body share={share} appUrl={appUrl} />, children: <Body share={share} appUrl={appUrl} />,
}); });
}; };
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => { const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals(); const modals = useModals();
const router = useRouter(); const router = useRouter();
const t = useTranslate();
const link = `${appUrl}/s/${share.id}`;
const link = `${appUrl}/share/${share.id}`;
return ( return (
<Stack align="stretch"> <Stack align="stretch">
<TextInput <CopyTextField link={link} />
readOnly
variant="filled"
value={link}
rightSection={
window.isSecureContext && (
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<TbCopy />
</ActionIcon>
)
}
/>
<Text <Text
size="xs" size="xs"
sx={(theme) => ({ sx={(theme) => ({
@@ -66,10 +43,10 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
> >
{/* If our share.expiration is timestamp 0, show a different message */} {/* If our share.expiration is timestamp 0, show a different message */}
{moment(share.expiration).unix() === 0 {moment(share.expiration).unix() === 0
? "This share will never expire." ? t("upload.modal.completed.never-expires")
: `This share will expire on ${moment(share.expiration).format( : t("upload.modal.completed.expires-on", {
"LLL" expiration: moment(share.expiration).format("LLL"),
)}`} })}
</Text> </Text>
<Button <Button
@@ -78,7 +55,7 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
router.push("/upload"); router.push("/upload");
}} }}
> >
Done <FormattedMessage id="common.button.done" />
</Button> </Button>
</Stack> </Stack>
); );

View File

@@ -5,6 +5,7 @@ import {
Checkbox, Checkbox,
Col, Col,
Grid, Grid,
Group,
MultiSelect, MultiSelect,
NumberInput, NumberInput,
PasswordInput, PasswordInput,
@@ -13,15 +14,19 @@ import {
Text, Text,
Textarea, Textarea,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react"; import { useState } from "react";
import { TbAlertCircle } from "react-icons/tb"; import { TbAlertCircle } from "react-icons/tb";
import { FormattedMessage } from "react-intl";
import * as yup from "yup"; import * as yup from "yup";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import shareService from "../../../services/share.service"; import shareService from "../../../services/share.service";
import { FileUpload } from "../../../types/File.type";
import { CreateShare } from "../../../types/share.type"; import { CreateShare } from "../../../types/share.type";
import { getExpirationPreview } from "../../../utils/date.util"; import { getExpirationPreview } from "../../../utils/date.util";
@@ -34,13 +39,17 @@ const showCreateUploadModal = (
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean; enableEmailRecepients: boolean;
}, },
uploadCallback: (createShare: CreateShare) => void files: FileUpload[],
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
) => { ) => {
const t = translateOutsideContext();
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Share</Title>, title: t("upload.modal.title"),
children: ( children: (
<CreateUploadModalBody <CreateUploadModalBody
options={options} options={options}
files={files}
uploadCallback={uploadCallback} uploadCallback={uploadCallback}
/> />
), ),
@@ -49,9 +58,11 @@ const showCreateUploadModal = (
const CreateUploadModalBody = ({ const CreateUploadModalBody = ({
uploadCallback, uploadCallback,
files,
options, options,
}: { }: {
uploadCallback: (createShare: CreateShare) => void; files: FileUpload[];
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
options: { options: {
isUserSignedIn: boolean; isUserSignedIn: boolean;
isReverseShare: boolean; isReverseShare: boolean;
@@ -61,24 +72,29 @@ const CreateUploadModalBody = ({
}; };
}) => { }) => {
const modals = useModals(); const modals = useModals();
const t = useTranslate();
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7);
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true); const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
link: yup link: yup
.string() .string()
.required() .required(t("common.error.field-required"))
.min(3) .min(3, t("common.error.too-short", { length: 3 }))
.max(50) .max(50, t("common.error.too-long", { length: 50 }))
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), { .matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: "Can only contain letters, numbers, underscores and hyphens", message: t("upload.modal.link.error.invalid"),
}), }),
password: yup.string().min(3).max(30), password: yup.string().min(3).max(30),
maxViews: yup.number().min(1), maxViews: yup.number().min(1),
}); });
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
link: "", link: generatedLink,
recipients: [] as string[], recipients: [] as string[],
password: undefined, password: undefined,
maxViews: undefined, maxViews: undefined,
@@ -96,61 +112,61 @@ const CreateUploadModalBody = ({
withCloseButton withCloseButton
onClose={() => setShowNotSignedInAlert(false)} onClose={() => setShowNotSignedInAlert(false)}
icon={<TbAlertCircle size={16} />} icon={<TbAlertCircle size={16} />}
title="You're not signed in" title={t("upload.modal.not-signed-in")}
color="yellow" color="yellow"
> >
You will be unable to delete your share manually and view the visitor <FormattedMessage id="upload.modal.not-signed-in-description" />
count.
</Alert> </Alert>
)} )}
<form <form
onSubmit={form.onSubmit(async (values) => { onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) { if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use"); form.setFieldError("link", t("upload.modal.link.error.taken"));
} else { } else {
const expiration = form.values.never_expires const expiration = form.values.never_expires
? "never" ? "never"
: form.values.expiration_num + form.values.expiration_unit; : form.values.expiration_num + form.values.expiration_unit;
uploadCallback({ uploadCallback(
id: values.link, {
expiration: expiration, id: values.link,
recipients: values.recipients, expiration: expiration,
description: values.description, recipients: values.recipients,
security: { description: values.description,
password: values.password, security: {
maxViews: values.maxViews, password: values.password,
maxViews: values.maxViews,
},
}, },
}); files
);
modals.closeAll(); modals.closeAll();
} }
})} })}
> >
<Stack align="stretch"> <Stack align="stretch">
<Grid align={form.errors.link ? "center" : "flex-end"}> <Group align="end">
<Col xs={9}> <TextInput
<TextInput style={{ flex: "1" }}
variant="filled" variant="filled"
label="Link" label={t("upload.modal.link.label")}
placeholder="myAwesomeShare" placeholder="myAwesomeShare"
{...form.getInputProps("link")} {...form.getInputProps("link")}
/> />
</Col> <Button
<Col xs={3}> style={{ flex: "0 0 auto" }}
<Button variant="outline"
variant="outline" onClick={() =>
onClick={() => form.setFieldValue(
form.setFieldValue( "link",
"link", Buffer.from(Math.random().toString(), "utf8")
Buffer.from(Math.random().toString(), "utf8") .toString("base64")
.toString("base64") .substr(10, 7)
.substr(10, 7) )
) }
} >
> <FormattedMessage id="common.button.generate" />
Generate </Button>
</Button> </Group>
</Col>
</Grid>
<Text <Text
italic italic
@@ -159,8 +175,7 @@ const CreateUploadModalBody = ({
color: theme.colors.gray[6], color: theme.colors.gray[6],
})} })}
> >
{options.appUrl}/share/ {`${options.appUrl}/s/${form.values.link}`}
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text> </Text>
{!options.isReverseShare && ( {!options.isReverseShare && (
<> <>
@@ -171,8 +186,7 @@ const CreateUploadModalBody = ({
max={99999} max={99999}
precision={0} precision={0}
variant="filled" variant="filled"
label="Expiration" label={t("upload.modal.expires.label")}
placeholder="n"
disabled={form.values.never_expires} disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")} {...form.getInputProps("expiration_num")}
/> />
@@ -186,41 +200,51 @@ const CreateUploadModalBody = ({
{ {
value: "-minutes", value: "-minutes",
label: label:
"Minute" + form.values.expiration_num == 1
(form.values.expiration_num == 1 ? "" : "s"), ? t("upload.modal.expires.minute-singular")
: t("upload.modal.expires.minute-plural"),
}, },
{ {
value: "-hours", value: "-hours",
label: label:
"Hour" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.hour-singular")
: t("upload.modal.expires.hour-plural"),
}, },
{ {
value: "-days", value: "-days",
label: label:
"Day" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.day-singular")
: t("upload.modal.expires.day-plural"),
}, },
{ {
value: "-weeks", value: "-weeks",
label: label:
"Week" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.week-singular")
: t("upload.modal.expires.week-plural"),
}, },
{ {
value: "-months", value: "-months",
label: label:
"Month" + form.values.expiration_num == 1
(form.values.expiration_num == 1 ? "" : "s"), ? t("upload.modal.expires.month-singular")
: t("upload.modal.expires.month-plural"),
}, },
{ {
value: "-years", value: "-years",
label: label:
"Year" + (form.values.expiration_num == 1 ? "" : "s"), form.values.expiration_num == 1
? t("upload.modal.expires.year-singular")
: t("upload.modal.expires.year-plural"),
}, },
]} ]}
/> />
</Col> </Col>
</Grid> </Grid>
<Checkbox <Checkbox
label="Never Expires" label={t("upload.modal.expires.never-long")}
{...form.getInputProps("never_expires")} {...form.getInputProps("never_expires")}
/> />
<Text <Text
@@ -230,18 +254,28 @@ const CreateUploadModalBody = ({
color: theme.colors.gray[6], color: theme.colors.gray[6],
})} })}
> >
{getExpirationPreview("share", form)} {getExpirationPreview(
{
neverExpires: t("upload.modal.completed.never-expires"),
expiresOn: t("upload.modal.completed.expires-on"),
},
form
)}
</Text> </Text>
</> </>
)} )}
<Accordion> <Accordion>
<Accordion.Item value="description" sx={{ borderBottom: "none" }}> <Accordion.Item value="description" sx={{ borderBottom: "none" }}>
<Accordion.Control>Description</Accordion.Control> <Accordion.Control>
<FormattedMessage id="upload.modal.accordion.description.title" />
</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<Stack align="stretch"> <Stack align="stretch">
<Textarea <Textarea
variant="filled" variant="filled"
placeholder="Note for the recepients" placeholder={t(
"upload.modal.accordion.description.placeholder"
)}
{...form.getInputProps("description")} {...form.getInputProps("description")}
/> />
</Stack> </Stack>
@@ -249,20 +283,22 @@ const CreateUploadModalBody = ({
</Accordion.Item> </Accordion.Item>
{options.enableEmailRecepients && ( {options.enableEmailRecepients && (
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}> <Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
<Accordion.Control>Email recipients</Accordion.Control> <Accordion.Control>
<FormattedMessage id="upload.modal.accordion.email.title" />
</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<MultiSelect <MultiSelect
data={form.values.recipients} data={form.values.recipients}
placeholder="Enter email recipients" placeholder={t("upload.modal.accordion.email.placeholder")}
searchable searchable
{...form.getInputProps("recipients")}
creatable creatable
autoComplete="email-recipients"
getCreateLabel={(query) => `+ ${query}`} getCreateLabel={(query) => `+ ${query}`}
onCreate={(query) => { onCreate={(query) => {
if (!query.match(/^\S+@\S+\.\S+$/)) { if (!query.match(/^\S+@\S+\.\S+$/)) {
form.setFieldError( form.setFieldError(
"recipients", "recipients",
"Invalid email address" t("upload.modal.accordion.email.invalid-email")
); );
} else { } else {
form.setFieldError("recipients", null); form.setFieldError("recipients", null);
@@ -273,34 +309,44 @@ const CreateUploadModalBody = ({
return query; return query;
} }
}} }}
{...form.getInputProps("recipients")}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
)} )}
<Accordion.Item value="security" sx={{ borderBottom: "none" }}> <Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control> <Accordion.Control>
<FormattedMessage id="upload.modal.accordion.security.title" />
</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<Stack align="stretch"> <Stack align="stretch">
<PasswordInput <PasswordInput
variant="filled" variant="filled"
placeholder="No password" placeholder={t(
label="Password protection" "upload.modal.accordion.security.password.placeholder"
)}
label={t("upload.modal.accordion.security.password.label")}
autoComplete="off"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<NumberInput <NumberInput
min={1} min={1}
type="number" type="number"
variant="filled" variant="filled"
placeholder="No limit" placeholder={t(
label="Maximal views" "upload.modal.accordion.security.max-views.placeholder"
)}
label={t("upload.modal.accordion.security.max-views.label")}
{...form.getInputProps("maxViews")} {...form.getInputProps("maxViews")}
/> />
</Stack> </Stack>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
<Button type="submit">Share</Button> <Button type="submit" data-autofocus>
<FormattedMessage id="common.button.share" />
</Button>
</Stack> </Stack>
</form> </form>
</> </>

View File

@@ -0,0 +1,39 @@
import { getCookie } from "cookies-next";
import { createIntl, createIntlCache, useIntl } from "react-intl";
import i18nUtil from "../utils/i18n.util";
const useTranslate = () => {
const intl = useIntl();
return (
id: string,
values?: Parameters<typeof intl.formatMessage>[1],
opts?: Parameters<typeof intl.formatMessage>[2],
) => {
return intl.formatMessage({ id }, values, opts) as string;
};
};
const cache = createIntlCache();
export const translateOutsideContext = () => {
const locale =
getCookie("language")?.toString() ?? navigator.language.split("-")[0];
const intl = createIntl(
{
locale,
messages: i18nUtil.getLocaleByCode(locale)?.messages,
defaultLocale: "en",
},
cache,
);
return (
id: string,
values?: Parameters<typeof intl.formatMessage>[1],
opts?: Parameters<typeof intl.formatMessage>[2],
) => {
return intl.formatMessage({ id }, values, opts) as string;
};
};
export default useTranslate;

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