Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b088a5ef2a | ||
|
|
c502cd58db | ||
|
|
97e7d7190d | ||
|
|
38919003e9 | ||
|
|
f15a8dc277 | ||
|
|
92927b1373 | ||
|
|
6a4108ed61 | ||
|
|
c9f1be2faf | ||
|
|
57be6945f2 | ||
|
|
82abe52ea5 | ||
|
|
6fa7af7905 | ||
|
|
13e7a30bb9 | ||
|
|
955af04e32 | ||
|
|
035e67f759 | ||
|
|
167ec782ef | ||
|
|
743c33475f | ||
|
|
3f1d3b7833 | ||
|
|
3d76e41cd8 | ||
|
|
e9efbc17bc | ||
|
|
307d176430 | ||
|
|
7e24ba9721 | ||
|
|
f9774d82d8 | ||
|
|
7647a9f620 | ||
|
|
d4e8d4f58b | ||
|
|
4df8dea5cc | ||
|
|
84aa100f84 | ||
|
|
bddb87b9b3 | ||
|
|
18c10c0ac6 | ||
|
|
f02e2979c4 | ||
|
|
7b34cb14cb | ||
|
|
019ef090ac | ||
|
|
7304b54125 | ||
|
|
ea0d5216e8 | ||
|
|
62deb6c152 | ||
|
|
9ba2b4c82c | ||
|
|
a47d080657 | ||
|
|
72a52eb33f | ||
|
|
c9a2a469c6 | ||
|
|
b534129194 | ||
|
|
0beebfd779 | ||
|
|
2ed5ecc1ea | ||
|
|
9bb05158c5 | ||
|
|
36230371fd | ||
|
|
5fd79a35cb | ||
|
|
cecaa90e15 | ||
|
|
2584bb0d48 | ||
|
|
82008aa261 | ||
|
|
a07a78a138 | ||
|
|
2618bbb897 | ||
|
|
6667c7a8d7 | ||
|
|
7f0c31c2e0 | ||
|
|
3165dcf9e6 | ||
|
|
f4c88aeb08 | ||
|
|
231a2e95b9 | ||
|
|
7827b687fa | ||
|
|
389dc87cac | ||
|
|
5816b39fc6 | ||
|
|
890588f5da | ||
|
|
e6a2014875 | ||
|
|
396363488c | ||
|
|
424331ed1a | ||
|
|
d198a132db | ||
|
|
a041a6969d | ||
|
|
be57bd3354 | ||
|
|
70b425b380 | ||
|
|
8259eb286c | ||
|
|
7071d8bd87 | ||
|
|
b2ed7b74c0 | ||
|
|
b9f6e3bd08 | ||
|
|
7c5ec8d0ea | ||
|
|
0276294f52 | ||
|
|
7574eb3191 | ||
|
|
a1ea7c0265 | ||
|
|
adf0f8d57e | ||
|
|
447c86f1c9 | ||
|
|
1466240461 | ||
|
|
348852cfa4 | ||
|
|
932496a121 | ||
|
|
0c7b2a8e70 | ||
|
|
1df5c7123e | ||
|
|
2dc0fc9332 | ||
|
|
98c0de78e8 | ||
|
|
5132d177b8 | ||
|
|
e5071cba12 | ||
|
|
b33c1d7f4b | ||
|
|
39a74510c1 | ||
|
|
b7db9b9b40 | ||
|
|
2ca0092b71 | ||
|
|
b4bf43910e | ||
|
|
90aa919694 | ||
|
|
f2e4019190 | ||
|
|
ffd4e43f11 | ||
|
|
0e5c673270 | ||
|
|
beece56327 | ||
|
|
a0d1d98e24 | ||
|
|
ca73ccf629 | ||
|
|
9f2097e788 | ||
|
|
2158df4228 | ||
|
|
37e765ddc7 | ||
|
|
a91c531642 | ||
|
|
5a7f7ca2f6 | ||
|
|
813ee4de2c | ||
|
|
b25c30d1ed | ||
|
|
c807d208d8 | ||
|
|
f82099f36e | ||
|
|
6345e21db9 | ||
|
|
f55aa80516 | ||
|
|
0ce8b528e1 | ||
|
|
8ff417a013 | ||
|
|
cb1a0d4090 | ||
|
|
753dbe83b7 | ||
|
|
0c2a62b0ca | ||
|
|
452c635933 | ||
|
|
0455ba1bc1 | ||
|
|
3ad6b03b6b | ||
|
|
91c3525b15 | ||
|
|
8403d7e14d | ||
|
|
8f71fd3435 | ||
|
|
155c743197 | ||
|
|
8b77e81d4c | ||
|
|
22d81b2220 | ||
|
|
0317f3a508 | ||
|
|
fddad3ef70 | ||
|
|
f9840505b8 | ||
|
|
759c55f625 | ||
|
|
edb511252f | ||
|
|
c3af0fe097 | ||
|
|
6419da07fb | ||
|
|
7cd9dff637 | ||
|
|
2a826f7941 | ||
|
|
8720232755 | ||
|
|
dc8cf3d5ca | ||
|
|
979b882150 | ||
|
|
c55019f71b | ||
|
|
4c6ef52a17 | ||
|
|
b9662701c4 | ||
|
|
e3f88d0826 | ||
|
|
86a7379519 | ||
|
|
ccdf8ea3ae | ||
|
|
edc10b72b7 | ||
|
|
5d1a7f0310 | ||
|
|
8ab359b71d | ||
|
|
38de022215 | ||
|
|
82f204e8a9 | ||
|
|
4e840ecd29 | ||
|
|
064ef38d78 | ||
|
|
b14e931d8d |
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: "🌐 Language request"
|
||||||
|
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||||
|
title: "🌐 Language request: <language name in english>"
|
||||||
|
labels: [language-request]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: language-name-native
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Language name (native)"
|
||||||
|
placeholder: "Schweizerdeutsch"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: language-code
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Language code"
|
||||||
|
placeholder: "de-CH"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
17
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: ❓ Question
|
||||||
|
description: "Submit a question"
|
||||||
|
title: "❓ Question:"
|
||||||
|
labels: [question]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🙋♂️ Question"
|
||||||
|
description: "A clear question. Please provide as much detail as possible."
|
||||||
|
placeholder: "How do I ...?"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting, please check if the question hasn't been asked before.
|
||||||
28
.github/workflows/build-docker-image.yml
vendored
@@ -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
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
!/backend/prisma/.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,4 +40,4 @@ yarn-error.log*
|
|||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Jetbrains specific (webstorm)
|
# Jetbrains specific (webstorm)
|
||||||
.idea/**/**
|
.idea/**/**
|
||||||
|
|||||||
238
CHANGELOG.md
@@ -1,3 +1,241 @@
|
|||||||
|
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
|
||||||
|
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
|
||||||
|
|
||||||
|
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
|
||||||
|
|
||||||
|
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
|
||||||
|
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
|
||||||
|
|
||||||
|
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
|
||||||
|
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
|
||||||
|
|
||||||
|
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
|
||||||
|
|
||||||
|
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
|
||||||
|
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
|
||||||
|
|
||||||
|
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* `ECONNREFUSED` with Docker ipv6 enabled ([c9a2a46](https://github.com/stonith404/pingvin-share/commit/c9a2a469c67d3c3cd08179b44e2bf82208f05177))
|
||||||
|
|
||||||
|
## [0.17.1](https://github.com/stonith404/pingvin-share/compare/v0.17.0...v0.17.1) (2023-07-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* rename pt-PT.ts to pt-BR.ts ([2584bb0](https://github.com/stonith404/pingvin-share/commit/2584bb0d48c761940eafc03d5cd98d47e7a5b0ae))
|
||||||
|
|
||||||
|
## [0.17.0](https://github.com/stonith404/pingvin-share/compare/v0.16.1...v0.17.0) (2023-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ability to define zip compression level ([7827b68](https://github.com/stonith404/pingvin-share/commit/7827b687fa022e86a2643e7a1951af8c7e80608c))
|
||||||
|
* add note to language picker ([7f0c31c](https://github.com/stonith404/pingvin-share/commit/7f0c31c2e09b3ee9aae6c3dfb54fac2f2b1dfe23))
|
||||||
|
* add share url alias `/s` ([231a2e9](https://github.com/stonith404/pingvin-share/commit/231a2e95b9734cf4704454e1945698753dbb378b))
|
||||||
|
* localization ([#196](https://github.com/stonith404/pingvin-share/issues/196)) ([b9f6e3b](https://github.com/stonith404/pingvin-share/commit/b9f6e3bd08dcfc050048fba582b35958bc7b6184))
|
||||||
|
* update default value of `maxSize` from `1073741824` to `1000000000` ([389dc87](https://github.com/stonith404/pingvin-share/commit/389dc87cac775d916d0cff9b71d3c5ff90bfe916))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* confusion between GB and GiB ([5816b39](https://github.com/stonith404/pingvin-share/commit/5816b39fc6ef6fe6b7cf8e7925aa297561f5b796))
|
||||||
|
* mistakes in English translations ([70b425b](https://github.com/stonith404/pingvin-share/commit/70b425b3807be79a3b518cc478996c71dffcf986))
|
||||||
|
* wrong layout if button text is too long in modals ([f4c88ae](https://github.com/stonith404/pingvin-share/commit/f4c88aeb0823c2c18535c25fcf8e16afa8b53a56))
|
||||||
|
|
||||||
|
### [0.16.1](https://github.com/stonith404/pingvin-share/compare/v0.16.0...v0.16.1) (2023-07-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Adding reverse share ability to copy the link ([#191](https://github.com/stonith404/pingvin-share/issues/191)) ([7574eb3](https://github.com/stonith404/pingvin-share/commit/7574eb3191f21aadd64f436e9e7c78d3e3973a07)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
||||||
|
* Adding reverse shares' shares a clickable link ([#190](https://github.com/stonith404/pingvin-share/issues/190)) ([0276294](https://github.com/stonith404/pingvin-share/commit/0276294f5219a7edcc762bc52391b6720cfd741d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* set link default value to random ([#192](https://github.com/stonith404/pingvin-share/issues/192)) ([a1ea7c0](https://github.com/stonith404/pingvin-share/commit/a1ea7c026594a54eafd52f764eecbf06e1bb4d4e)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
||||||
|
|
||||||
|
## [0.16.0](https://github.com/stonith404/pingvin-share/compare/v0.15.0...v0.16.0) (2023-07-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Adding more informations on My Shares page (table and modal) ([#174](https://github.com/stonith404/pingvin-share/issues/174)) ([1466240](https://github.com/stonith404/pingvin-share/commit/14662404614f15bc25384d924d8cb0458ab06cd8))
|
||||||
|
* Adding the possibility of copying the link by clicking text and icons ([#171](https://github.com/stonith404/pingvin-share/issues/171)) ([348852c](https://github.com/stonith404/pingvin-share/commit/348852cfa4275f5c642669b43697f83c35333044))
|
||||||
|
|
||||||
|
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add env variables for port, database url and data dir ([98c0de7](https://github.com/stonith404/pingvin-share/commit/98c0de78e8a73e3e5bf0928226cfb8a024b566a1))
|
||||||
|
* add healthcheck endpoint ([5132d17](https://github.com/stonith404/pingvin-share/commit/5132d177b8ab4e00a7e701e9956222fa2352d42c))
|
||||||
|
* allow to configure clamav with environment variables ([1df5c71](https://github.com/stonith404/pingvin-share/commit/1df5c7123e4ca8695f4f1b7d49f46cdf147fb920))
|
||||||
|
* configure ports, db url and api url with env variables ([e5071cb](https://github.com/stonith404/pingvin-share/commit/e5071cba1204093197b72e18d024b484e72e360a))
|
||||||
|
|
||||||
|
### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* boolean config variables can't be set to false ([39a7451](https://github.com/stonith404/pingvin-share/commit/39a74510c1f00466acaead39f7bee003b3db60d7))
|
||||||
|
|
||||||
|
## [0.14.0](https://github.com/stonith404/pingvin-share/compare/v0.13.1...v0.14.0) (2023-04-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **share, config:** more variables, placeholder and reset default ([#132](https://github.com/stonith404/pingvin-share/issues/132)) ([beece56](https://github.com/stonith404/pingvin-share/commit/beece56327da141c222fd9f5259697df6db9347a))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bool config variable can't be changed ([0e5c673](https://github.com/stonith404/pingvin-share/commit/0e5c67327092e4751208e559a2b0d5ee2b91b6e3))
|
||||||
|
|
||||||
|
### [0.13.1](https://github.com/stonith404/pingvin-share/compare/v0.13.0...v0.13.1) (2023-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* empty file can't be uploaded in chrome ([9f2097e](https://github.com/stonith404/pingvin-share/commit/9f2097e788dfb79c2f95085025934c3134a3eb38))
|
||||||
|
|
||||||
|
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
|
||||||
|
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
|
||||||
|
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
|
||||||
|
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
|
||||||
|
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
|
||||||
|
|
||||||
|
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* 48px icon does not update ([753dbe8](https://github.com/stonith404/pingvin-share/commit/753dbe83b770814115a2576c7a50e1bac9dc8ce1))
|
||||||
|
|
||||||
|
## [0.12.0](https://github.com/stonith404/pingvin-share/compare/v0.11.1...v0.12.0) (2023-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ability to change logo in frontend ([8403d7e](https://github.com/stonith404/pingvin-share/commit/8403d7e14ded801c3842a9b3fd87c3f6824c519e))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* crypto is not defined ([8f71fd3](https://github.com/stonith404/pingvin-share/commit/8f71fd343506506532c1a24a4c66a16b1021705f))
|
||||||
|
* home page shown even if disabled ([3ad6b03](https://github.com/stonith404/pingvin-share/commit/3ad6b03b6bd80168870049582683077b689fa548))
|
||||||
|
|
||||||
|
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* old config variable prevents to create a share ([8b77e81](https://github.com/stonith404/pingvin-share/commit/8b77e81d4c1b8a2bf798595f5a66079c40734e09))
|
||||||
|
|
||||||
|
## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom branding ([#112](https://github.com/stonith404/pingvin-share/issues/112)) ([fddad3e](https://github.com/stonith404/pingvin-share/commit/fddad3ef708c27052a8bf46f3076286d102f6d7e))
|
||||||
|
* invite new user with email ([f984050](https://github.com/stonith404/pingvin-share/commit/f9840505b82fcb04364a79576f186b76cc75f5c0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* frontend error when user deleted ([0317f3a](https://github.com/stonith404/pingvin-share/commit/0317f3a508dc88ffe2c33413704f7df03a2372ea))
|
||||||
|
|
||||||
|
### [0.10.2](https://github.com/stonith404/pingvin-share/compare/v0.10.1...v0.10.2) (2023-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pdf preview tries to render on server ([c3af0fe](https://github.com/stonith404/pingvin-share/commit/c3af0fe097582f69b63ed1ad18fb71bff334d32a))
|
||||||
|
|
||||||
|
### [0.10.1](https://github.com/stonith404/pingvin-share/compare/v0.10.0...v0.10.1) (2023-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* non administrator user redirection error while setup isn't finished ([dc8cf3d](https://github.com/stonith404/pingvin-share/commit/dc8cf3d5ca6b4f8a8f243b8e0b05e09738cf8b61))
|
||||||
|
* setup wizard doesn't redirect after completion ([7cd9dff](https://github.com/stonith404/pingvin-share/commit/7cd9dff637900098c9f6e46ccade37283d47321b))
|
||||||
|
|
||||||
|
## [0.10.0](https://github.com/stonith404/pingvin-share/compare/v0.9.0...v0.10.0) (2023-02-10)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* reset password with email
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* allow multiple shares with one reverse share link ([ccdf8ea](https://github.com/stonith404/pingvin-share/commit/ccdf8ea3ae1e7b8520c5b1dd9bea18b1b3305f35))
|
||||||
|
* **frontend:** server side rendering to improve performance ([38de022](https://github.com/stonith404/pingvin-share/commit/38de022215a9b99c2eb36654f8dbb1e17ca87aba))
|
||||||
|
* reset password with email ([5d1a7f0](https://github.com/stonith404/pingvin-share/commit/5d1a7f0310df2643213affd2a0d1785b7e0af398))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* delete all shares of reverse share ([86a7379](https://github.com/stonith404/pingvin-share/commit/86a737951951c911abd7967d76cb253c4335cb0c))
|
||||||
|
* invalid redirection after jwt expiry ([82f204e](https://github.com/stonith404/pingvin-share/commit/82f204e8a93e3113dcf65b1881d4943a898602eb))
|
||||||
|
* setup status doesn't change ([064ef38](https://github.com/stonith404/pingvin-share/commit/064ef38d783b3f351535c2911eb451efd9526d71))
|
||||||
|
* share creation without reverseShareToken ([b966270](https://github.com/stonith404/pingvin-share/commit/b9662701c42fe6771c07acb869564031accb2932))
|
||||||
|
* share fails if a share was created with a reverse share link recently ([edc10b7](https://github.com/stonith404/pingvin-share/commit/edc10b72b7884c629a8417c3c82222b135ef7653))
|
||||||
|
|
||||||
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
|
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||||
@@ -8,62 +12,55 @@ You've found a bug, have suggestion or something else, just create an issue on G
|
|||||||
|
|
||||||
## Submit a Pull Request
|
## Submit a Pull Request
|
||||||
|
|
||||||
Once you created a issue and you want to create a pull request, follow this guide.
|
Before you submit the pull request for review please ensure that
|
||||||
|
|
||||||
Branch naming convention is as following
|
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||||
|
|
||||||
`TYPE-ISSUE_ID-DESCRIPTION`
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
example:
|
example:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): add password protection
|
||||||
|
```
|
||||||
|
|
||||||
|
When `TYPE` can be:
|
||||||
|
|
||||||
|
- **feat** - is a new feature
|
||||||
|
- **doc** - documentation only changes
|
||||||
|
- **fix** - a bug fix
|
||||||
|
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||||
|
|
||||||
|
- Your pull request has a detailed description
|
||||||
|
- You run `npm run format` to format the code
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
|
||||||
|
|
||||||
|
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
|
||||||
|
|
||||||
|
2. Clone your fork to your machine with `git clone`
|
||||||
|
|
||||||
```
|
```
|
||||||
feat-69-ability-to-set-share-expiration-to-never
|
$ git clone https://github.com/[your_username]/pingvin-share
|
||||||
```
|
|
||||||
|
|
||||||
When `TYPE` can be:
|
|
||||||
|
|
||||||
- **feat** - is a new feature
|
|
||||||
- **doc** - documentation only changes
|
|
||||||
- **fix** - a bug fix
|
|
||||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
|
||||||
|
|
||||||
**All PRs must include a commit message with the changes description!**
|
|
||||||
|
|
||||||
For the initial start, fork the project and use the `git clone` command to download the repository to your computer. A standard procedure for working on an issue would be to:
|
|
||||||
|
|
||||||
1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git pull
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create new branch from `main` like: `feat-69-ability-to-set-share-expiration-to-never`<br/>
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git checkout -b [name_of_your_new_branch]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Work - commit - repeat
|
3. Work - commit - repeat
|
||||||
|
|
||||||
4. Before you push your changes, make sure you run the linter and format the code.
|
4. Push changes to GitHub
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
npm run format
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Push changes to GitHub
|
|
||||||
|
|
||||||
```
|
```
|
||||||
$ git push origin [name_of_your_new_branch]
|
$ git push origin [name_of_your_new_branch]
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Submit your changes for review
|
5. Submit your changes for review
|
||||||
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
||||||
7. Start a Pull Request
|
6. Start a Pull Request
|
||||||
Now submit the pull request and click on `Create pull request`.
|
7. Now submit the pull request and click on `Create pull request`.
|
||||||
8. Get a code review approval/reject
|
8. Get a code review approval/reject
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Setup project
|
## Setup project
|
||||||
|
|
||||||
Pingvin Share consists of a frontend and a backend.
|
Pingvin Share consists of a frontend and a backend.
|
||||||
|
|||||||
43
Dockerfile
@@ -1,44 +1,46 @@
|
|||||||
# 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 nginx
|
||||||
|
|
||||||
|
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
||||||
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
||||||
|
COPY --from=frontend-builder /opt/app/public/img /tmp/img
|
||||||
|
|
||||||
WORKDIR /opt/app/backend
|
WORKDIR /opt/app/backend
|
||||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||||
@@ -47,5 +49,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 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 && nginx && PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
|
||||||
134
README.md
@@ -1,39 +1,77 @@
|
|||||||
# <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
|
||||||
|
|
||||||
- Create a share with files that you can access with a link
|
- Share files using a link
|
||||||
- No file size limit, only your disk will be your limit
|
- Unlimited file size (restricted only by disk space)
|
||||||
- Set a share expiration
|
- Set an expiration date for shares
|
||||||
- Optionally secure your share with a visitor limit and a password
|
- Secure shares with visitor limits and passwords
|
||||||
- Email recepients
|
- Email recipients
|
||||||
- ClamAV integration
|
- Integration with ClamAV for security scans
|
||||||
|
|
||||||
## 🐧 Get to know Pingvin Share
|
## 🐧 Get to know Pingvin Share
|
||||||
|
|
||||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
|
|
||||||
## ⌨️ Setup
|
## ⌨️ Setup
|
||||||
|
|
||||||
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
> Note: Pingvin Share is in its early stages and may contain bugs.
|
||||||
|
|
||||||
### Recommended installation
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` file
|
1. Download the `docker-compose.yml` file
|
||||||
2. Run `docker-compose up -d`
|
2. Run `docker-compose up -d`
|
||||||
|
|
||||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Stand-alone Installation
|
||||||
|
|
||||||
|
Required tools:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Nginx can be found in `/nginx/nginx.conf`.
|
||||||
|
|
||||||
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
#### ClamAV
|
#### ClamAV (Docker only)
|
||||||
|
|
||||||
With ClamAV the shares get scanned for malicious files and get removed if any found.
|
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||||
|
|
||||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||||
@@ -47,8 +85,76 @@ Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manu
|
|||||||
|
|
||||||
### Upgrade to a new version
|
### Upgrade to a new version
|
||||||
|
|
||||||
Run `docker compose pull && docker compose up -d` to update your docker container
|
As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stand-alone
|
||||||
|
|
||||||
|
1. Stop the running app
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||||
|
|
||||||
|
#### Environment variables
|
||||||
|
|
||||||
|
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||||
|
|
||||||
|
##### Backend
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||||
|
| `PORT` | `8080` | The port on which the backend listens. |
|
||||||
|
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||||
|
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||||
|
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||||
|
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||||
|
|
||||||
|
##### Frontend
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
| --------- | ----------------------- | ---------------------------------------- |
|
||||||
|
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||||
|
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||||
|
|
||||||
## 🖤 Contribute
|
## 🖤 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
@@ -0,0 +1 @@
|
|||||||
|
/src/constants.ts
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src"
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": ["@nestjs/swagger"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5144
backend/package-lock.json
generated
@@ -1,77 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.9.0",
|
"version": "0.18.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --end-of-line=auto --write 'src/**/*.ts'",
|
||||||
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/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/mapped-types": "^1.2.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/platform-express": "^10.1.2",
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
"@nestjs/schedule": "^3.0.1",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@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
@@ -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"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateTable
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "ResetPasswordToken" (
|
||||||
|
"token" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Disable TOTP as secret isn't encrypted anymore
|
||||||
|
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
CREATE TABLE "new_Share" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"views" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"expiration" DATETIME NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"removedReason" TEXT,
|
||||||
|
"creatorId" TEXT,
|
||||||
|
"reverseShareId" TEXT,
|
||||||
|
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
|
||||||
|
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
|
||||||
|
FROM "Share";
|
||||||
|
|
||||||
|
|
||||||
|
DROP TABLE "Share";
|
||||||
|
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||||
|
CREATE TABLE "new_ReverseShare" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"shareExpiration" DATETIME NOT NULL,
|
||||||
|
"maxShareSize" TEXT NOT NULL,
|
||||||
|
"sendEmailNotification" BOOLEAN NOT NULL,
|
||||||
|
"remainingUses" INTEGER NOT NULL,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare";
|
||||||
|
DROP TABLE "ReverseShare";
|
||||||
|
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||||
|
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Config" (
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"order" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("name", "category")
|
||||||
|
);
|
||||||
|
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||||
|
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
|
||||||
|
|
||||||
|
|
||||||
|
DROP TABLE "Config";
|
||||||
|
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
@@ -22,9 +22,10 @@ model User {
|
|||||||
loginTokens LoginToken[]
|
loginTokens LoginToken[]
|
||||||
reverseShares ReverseShare[]
|
reverseShares ReverseShare[]
|
||||||
|
|
||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
totpVerified Boolean @default(false)
|
totpVerified Boolean @default(false)
|
||||||
totpSecret String?
|
totpSecret String?
|
||||||
|
resetPasswordToken ResetPasswordToken?
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@@ -49,6 +50,16 @@ model LoginToken {
|
|||||||
used Boolean @default(false)
|
used Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ResetPasswordToken {
|
||||||
|
token String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model Share {
|
model Share {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -63,7 +74,8 @@ model Share {
|
|||||||
creatorId String?
|
creatorId String?
|
||||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
reverseShare ReverseShare?
|
reverseShareId String?
|
||||||
|
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
security ShareSecurity?
|
security ShareSecurity?
|
||||||
recipients ShareRecipient[]
|
recipients ShareRecipient[]
|
||||||
@@ -78,13 +90,12 @@ model ReverseShare {
|
|||||||
shareExpiration DateTime
|
shareExpiration DateTime
|
||||||
maxShareSize String
|
maxShareSize String
|
||||||
sendEmailNotification Boolean
|
sendEmailNotification Boolean
|
||||||
used Boolean @default(false)
|
remainingUses Int
|
||||||
|
|
||||||
creatorId String
|
creatorId String
|
||||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
shareId String? @unique
|
shares Share[]
|
||||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model ShareRecipient {
|
model ShareRecipient {
|
||||||
@@ -120,13 +131,15 @@ model ShareSecurity {
|
|||||||
model Config {
|
model Config {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
key String @id
|
name String
|
||||||
type String
|
category String
|
||||||
value String
|
type String
|
||||||
description String
|
defaultValue String @default("")
|
||||||
category String
|
value String?
|
||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
|
@@id([name, category])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +1,220 @@
|
|||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
const configVariables: Prisma.ConfigCreateInput[] = [
|
const configVariables: ConfigVariables = {
|
||||||
{
|
internal: {
|
||||||
order: 0,
|
jwtSecret: {
|
||||||
key: "SETUP_STATUS",
|
type: "string",
|
||||||
description: "Status of the setup wizard",
|
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||||
type: "string",
|
locked: true,
|
||||||
value: "STARTED", // STARTED, REGISTERED, FINISHED
|
},
|
||||||
category: "internal",
|
|
||||||
secret: false,
|
|
||||||
locked: true,
|
|
||||||
},
|
},
|
||||||
{
|
general: {
|
||||||
order: 0,
|
appName: {
|
||||||
key: "JWT_SECRET",
|
type: "string",
|
||||||
description: "Long random string used to sign JWT tokens",
|
defaultValue: "Pingvin Share",
|
||||||
type: "string",
|
secret: false,
|
||||||
value: crypto.randomBytes(256).toString("base64"),
|
},
|
||||||
category: "internal",
|
appUrl: {
|
||||||
locked: true,
|
type: "string",
|
||||||
|
defaultValue: "http://localhost:3000",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
showHomePage: {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "true",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
share: {
|
||||||
order: 0,
|
allowRegistration: {
|
||||||
key: "TOTP_SECRET",
|
type: "boolean",
|
||||||
description: "A 16 byte random string used to generate TOTP secrets",
|
defaultValue: "true",
|
||||||
type: "string",
|
secret: false,
|
||||||
value: crypto.randomBytes(16).toString("base64"),
|
},
|
||||||
category: "internal",
|
allowUnauthenticatedShares: {
|
||||||
locked: true,
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
maxSize: {
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "1000000000",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
zipCompressionLevel: {
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "9",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
email: {
|
||||||
order: 1,
|
enableShareEmailRecipients: {
|
||||||
key: "APP_URL",
|
type: "boolean",
|
||||||
description: "On which URL Pingvin Share is available",
|
defaultValue: "false",
|
||||||
type: "string",
|
|
||||||
value: "http://localhost:3000",
|
|
||||||
category: "general",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
key: "SHOW_HOME_PAGE",
|
|
||||||
description: "Whether to show the home page",
|
|
||||||
type: "boolean",
|
|
||||||
value: "true",
|
|
||||||
category: "general",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
key: "ALLOW_REGISTRATION",
|
|
||||||
description: "Whether registration is allowed",
|
|
||||||
type: "boolean",
|
|
||||||
value: "true",
|
|
||||||
category: "share",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
|
||||||
description: "Whether unauthorized users can create shares",
|
|
||||||
type: "boolean",
|
|
||||||
value: "false",
|
|
||||||
category: "share",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
|
|
||||||
key: "MAX_SHARE_SIZE",
|
secret: false,
|
||||||
description: "Maximum share size in bytes",
|
},
|
||||||
type: "number",
|
shareRecipientsSubject: {
|
||||||
value: "1073741824",
|
type: "string",
|
||||||
category: "share",
|
defaultValue: "Files shared with you",
|
||||||
secret: false,
|
},
|
||||||
|
shareRecipientsMessage: {
|
||||||
|
type: "text",
|
||||||
|
defaultValue:
|
||||||
|
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||||
|
},
|
||||||
|
reverseShareSubject: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "Reverse share link used",
|
||||||
|
},
|
||||||
|
reverseShareMessage: {
|
||||||
|
type: "text",
|
||||||
|
defaultValue:
|
||||||
|
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||||
|
},
|
||||||
|
resetPasswordSubject: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "Pingvin Share password reset",
|
||||||
|
},
|
||||||
|
resetPasswordMessage: {
|
||||||
|
type: "text",
|
||||||
|
defaultValue:
|
||||||
|
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||||
|
},
|
||||||
|
inviteSubject: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "Pingvin Share invite",
|
||||||
|
},
|
||||||
|
inviteMessage: {
|
||||||
|
type: "text",
|
||||||
|
defaultValue:
|
||||||
|
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
smtp: {
|
||||||
|
enabled: {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
port: {
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "0",
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
obscured: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
{
|
type ConfigVariables = {
|
||||||
order: 6,
|
[category: string]: {
|
||||||
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
|
[variable: string]: Omit<
|
||||||
description:
|
Prisma.ConfigCreateInput,
|
||||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
"name" | "category" | "order"
|
||||||
type: "boolean",
|
>;
|
||||||
value: "false",
|
};
|
||||||
category: "email",
|
};
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 7,
|
|
||||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
|
||||||
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",
|
|
||||||
value:
|
|
||||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
|
||||||
category: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 8,
|
|
||||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent to the share recipients.",
|
|
||||||
type: "string",
|
|
||||||
value: "Files shared with you",
|
|
||||||
category: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 9,
|
|
||||||
key: "REVERSE_SHARE_EMAIL_MESSAGE",
|
|
||||||
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",
|
|
||||||
value:
|
|
||||||
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
|
||||||
category: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
|
||||||
type: "string",
|
|
||||||
value: "Reverse share link used",
|
|
||||||
category: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
key: "SMTP_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",
|
|
||||||
value: "false",
|
|
||||||
category: "smtp",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 12,
|
|
||||||
key: "SMTP_HOST",
|
|
||||||
description: "Host of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 13,
|
|
||||||
key: "SMTP_PORT",
|
|
||||||
description: "Port of the SMTP server",
|
|
||||||
type: "number",
|
|
||||||
value: "0",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 14,
|
|
||||||
key: "SMTP_EMAIL",
|
|
||||||
description: "Email address which the emails get sent from",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 15,
|
|
||||||
key: "SMTP_USERNAME",
|
|
||||||
description: "Username of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 16,
|
|
||||||
key: "SMTP_PASSWORD",
|
|
||||||
description: "Password of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
obscured: true,
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 main() {
|
async function seedConfigVariables() {
|
||||||
for (const variable of configVariables) {
|
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||||
const existingConfigVariable = await prisma.config.findUnique({
|
configVariables
|
||||||
where: { key: variable.key },
|
)) {
|
||||||
});
|
let order = 0;
|
||||||
|
for (const [name, properties] of Object.entries(
|
||||||
// Create a new config variable if it doesn't exist
|
configVariablesOfCategory
|
||||||
if (!existingConfigVariable) {
|
)) {
|
||||||
await prisma.config.create({
|
const existingConfigVariable = await prisma.config.findUnique({
|
||||||
data: variable,
|
where: { name_category: { name, category } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a new config variable if it doesn't exist
|
||||||
|
if (!existingConfigVariable) {
|
||||||
|
await prisma.config.create({
|
||||||
|
data: {
|
||||||
|
order,
|
||||||
|
name,
|
||||||
|
...properties,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
order++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configVariablesFromDatabase = await prisma.config.findMany();
|
async function migrateConfigVariables() {
|
||||||
|
const existingConfigVariables = await prisma.config.findMany();
|
||||||
|
|
||||||
// Delete the config variable if it doesn't exist anymore
|
for (const existingConfigVariable of existingConfigVariables) {
|
||||||
for (const configVariableFromDatabase of configVariablesFromDatabase) {
|
const configVariable =
|
||||||
const configVariable = configVariables.find(
|
configVariables[existingConfigVariable.category]?.[
|
||||||
(v) => v.key == configVariableFromDatabase.key
|
existingConfigVariable.name
|
||||||
);
|
];
|
||||||
if (!configVariable) {
|
if (!configVariable) {
|
||||||
await prisma.config.delete({
|
await prisma.config.delete({
|
||||||
where: { key: configVariableFromDatabase.key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
name: existingConfigVariable.name,
|
||||||
|
category: existingConfigVariable.category,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the config variable if the metadata changed
|
// Update the config variable if the metadata changed
|
||||||
} else if (
|
} else if (
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...configVariable,
|
...configVariable,
|
||||||
key: configVariableFromDatabase.key,
|
name: existingConfigVariable.name,
|
||||||
value: configVariableFromDatabase.value,
|
category: existingConfigVariable.category,
|
||||||
}) != JSON.stringify(configVariableFromDatabase)
|
value: existingConfigVariable.value,
|
||||||
|
}) != JSON.stringify(existingConfigVariable)
|
||||||
) {
|
) {
|
||||||
await prisma.config.update({
|
await prisma.config.update({
|
||||||
where: { key: configVariableFromDatabase.key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
name: existingConfigVariable.name,
|
||||||
|
category: existingConfigVariable.category,
|
||||||
|
},
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
...configVariable,
|
...configVariable,
|
||||||
key: configVariableFromDatabase.key,
|
name: existingConfigVariable.name,
|
||||||
value: configVariableFromDatabase.value,
|
category: existingConfigVariable.category,
|
||||||
|
value: existingConfigVariable.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main()
|
|
||||||
|
seedConfigVariables()
|
||||||
|
.then(() => migrateConfigVariables())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
})
|
})
|
||||||
|
|||||||
19
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get, Res } from "@nestjs/common";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { PrismaService } from "./prisma/prisma.service";
|
||||||
|
|
||||||
|
@Controller("/")
|
||||||
|
export class AppController {
|
||||||
|
constructor(private prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
@Get("health")
|
||||||
|
async health(@Res({ passthrough: true }) res: Response) {
|
||||||
|
try {
|
||||||
|
await this.prismaService.config.findMany();
|
||||||
|
return "OK";
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
return "ERROR";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { ShareModule } from "./share/share.module";
|
|||||||
import { UserModule } from "./user/user.module";
|
import { UserModule } from "./user/user.module";
|
||||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||||
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -33,6 +34,9 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
|||||||
ClamScanModule,
|
ClamScanModule,
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
],
|
],
|
||||||
|
controllers:[
|
||||||
|
AppController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
|||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||||
|
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||||
import { TokenDTO } from "./dto/token.dto";
|
import { TokenDTO } from "./dto/token.dto";
|
||||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||||
@@ -31,16 +33,16 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private authTotpService: AuthTotpService,
|
private authTotpService: AuthTotpService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
async signUp(
|
async signUp(
|
||||||
@Body() dto: AuthRegisterDTO,
|
@Body() dto: AuthRegisterDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("share.allowRegistration"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
@@ -48,18 +50,18 @@ export class AuthController {
|
|||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signIn(
|
async signIn(
|
||||||
@Body() dto: AuthSignInDTO,
|
@Body() dto: AuthSignInDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.signIn(dto);
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
@@ -67,42 +69,56 @@ export class AuthController {
|
|||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signInTotp(
|
async signInTotp(
|
||||||
@Body() dto: AuthSignInTotpDTO,
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.authTotpService.signInTotp(dto);
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("resetPassword/:email")
|
||||||
|
@Throttle(5, 5 * 60)
|
||||||
|
@HttpCode(204)
|
||||||
|
async requestResetPassword(@Param("email") email: string) {
|
||||||
|
return await this.authService.requestResetPassword(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("resetPassword")
|
||||||
|
@Throttle(5, 5 * 60)
|
||||||
|
@HttpCode(204)
|
||||||
|
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||||
|
return await this.authService.resetPassword(dto.token, dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("password")
|
@Patch("password")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async updatePassword(
|
async updatePassword(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() dto: UpdatePasswordDTO
|
@Body() dto: UpdatePasswordDTO,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.updatePassword(
|
const result = await this.authService.updatePassword(
|
||||||
user,
|
user,
|
||||||
dto.oldPassword,
|
dto.oldPassword,
|
||||||
dto.password
|
dto.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
response = this.addTokensToResponse(response, result.refreshToken);
|
response = this.addTokensToResponse(response, result.refreshToken);
|
||||||
@@ -113,21 +129,21 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
request.cookies.refresh_token
|
request.cookies.refresh_token,
|
||||||
);
|
);
|
||||||
response.cookie("access_token", accessToken);
|
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||||
return new TokenDTO().from({ accessToken });
|
return new TokenDTO().from({ accessToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("signOut")
|
@Post("signOut")
|
||||||
async signOut(
|
async signOut(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
await this.authService.signOut(request.cookies.access_token);
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
@@ -160,13 +176,15 @@ export class AuthController {
|
|||||||
private addTokensToResponse(
|
private addTokensToResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
refreshToken?: string,
|
refreshToken?: string,
|
||||||
accessToken?: string
|
accessToken?: string,
|
||||||
) {
|
) {
|
||||||
if (accessToken) response.cookie("access_token", accessToken);
|
if (accessToken)
|
||||||
|
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||||
if (refreshToken)
|
if (refreshToken)
|
||||||
response.cookie("refresh_token", refreshToken, {
|
response.cookie("refresh_token", refreshToken, {
|
||||||
path: "/api/auth/token",
|
path: "/api/auth/token",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthTotpService } from "./authTotp.service";
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({}), EmailModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ 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";
|
||||||
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
@@ -19,11 +20,12 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
|
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||||
|
|
||||||
const hash = await argon.hash(dto.password);
|
const hash = await argon.hash(dto.password);
|
||||||
try {
|
try {
|
||||||
@@ -36,12 +38,8 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFirstUser) {
|
|
||||||
await this.config.changeSetupStatus("REGISTERED");
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -51,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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,13 +78,57 @@ 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);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestResetPassword(email: string) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { email },
|
||||||
|
include: { resetPasswordToken: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new BadRequestException("User not found");
|
||||||
|
|
||||||
|
// Delete old reset password token
|
||||||
|
if (user.resetPasswordToken) {
|
||||||
|
await this.prisma.resetPasswordToken.delete({
|
||||||
|
where: { token: user.resetPasswordToken.token },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await this.prisma.resetPasswordToken.create({
|
||||||
|
data: {
|
||||||
|
expiresAt: moment().add(1, "hour").toDate(),
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { resetPasswordToken: { token } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||||
|
|
||||||
|
const newPasswordHash = await argon.hash(newPassword);
|
||||||
|
|
||||||
|
await this.prisma.resetPasswordToken.delete({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: newPasswordHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||||
if (!(await argon.verify(user.password, oldPassword)))
|
if (!(await argon.verify(user.password, oldPassword)))
|
||||||
throw new ForbiddenException("Invalid password");
|
throw new ForbiddenException("Invalid password");
|
||||||
@@ -110,26 +152,30 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
refreshTokenId,
|
refreshTokenId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut(accessToken: string) {
|
async signOut(accessToken: string) {
|
||||||
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
const { refreshTokenId } =
|
||||||
refreshTokenId: string;
|
(this.jwtService.decode(accessToken) as {
|
||||||
};
|
refreshTokenId: string;
|
||||||
|
}) || {};
|
||||||
|
|
||||||
await this.prisma.refreshToken
|
if (refreshTokenId) {
|
||||||
.delete({ where: { id: refreshTokenId } })
|
await this.prisma.refreshToken
|
||||||
.catch((e) => {
|
.delete({ where: { id: refreshTokenId } })
|
||||||
// Ignore error if refresh token doesn't exist
|
.catch((e) => {
|
||||||
if (e.code != "P2025") throw e;
|
// Ignore error if refresh token doesn't exist
|
||||||
});
|
if (e.code != "P2025") throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string) {
|
async refreshAccessToken(refreshToken: string) {
|
||||||
@@ -143,7 +189,7 @@ export class AuthService {
|
|||||||
|
|
||||||
return this.createAccessToken(
|
return this.createAccessToken(
|
||||||
refreshTokenMetaData.user,
|
refreshTokenMetaData.user,
|
||||||
refreshTokenMetaData.id
|
refreshTokenMetaData.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { authenticator, totp } from "otplib";
|
import { authenticator, totp } from "otplib";
|
||||||
import * as qrcode from "qrcode-svg";
|
import * as qrcode from "qrcode-svg";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
@@ -17,9 +16,9 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthTotpService {
|
export class AuthTotpService {
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private authService: AuthService
|
private authService: AuthService,
|
||||||
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||||
@@ -45,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({
|
||||||
@@ -57,9 +56,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (dto.totp !== expected) {
|
if (dto.totp !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
@@ -75,47 +72,12 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptTotpSecret(totpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
||||||
|
|
||||||
let encrypted = cipher.update(totpSecret);
|
|
||||||
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
|
|
||||||
return encrypted.toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
||||||
let decrypted = decipher.update(encryptedText);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
|
|
||||||
return decrypted.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableTotp(user: User, password: string) {
|
async enableTotp(user: User, password: string) {
|
||||||
if (!(await argon.verify(user.password, password)))
|
if (!(await argon.verify(user.password, password)))
|
||||||
throw new ForbiddenException("Invalid password");
|
throw new ForbiddenException("Invalid password");
|
||||||
@@ -132,19 +94,18 @@ export class AuthTotpService {
|
|||||||
|
|
||||||
// TODO: Maybe make the issuer configurable with env vars?
|
// TODO: Maybe make the issuer configurable with env vars?
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
|
||||||
|
|
||||||
const otpURL = totp.keyuri(
|
const otpURL = totp.keyuri(
|
||||||
user.username || user.email,
|
user.username || user.email,
|
||||||
"pingvin-share",
|
this.config.get("general.appName"),
|
||||||
secret
|
secret,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
totpSecret: encryptedSecret,
|
totpSecret: secret,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,9 +138,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not in progress");
|
throw new BadRequestException("TOTP is not in progress");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
if (code !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
@@ -208,9 +167,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
if (code !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class AuthRegisterDTO extends PickType(UserDTO, [
|
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { IsString } from "class-validator";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
|
||||||
|
|
||||||
export class AuthSignInTotpDTO extends PickType(UserDTO, [
|
|
||||||
"password",
|
|
||||||
] as const) {
|
|
||||||
@IsEmail()
|
|
||||||
@IsOptional()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
|
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
totp: string;
|
totp: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||||
|
|||||||
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { IsString } from "class-validator";
|
||||||
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") {
|
|||||||
try {
|
try {
|
||||||
return (await super.canActivate(context)) as boolean;
|
return (await super.canActivate(context)) as boolean;
|
||||||
} catch {
|
} catch {
|
||||||
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
return this.config.get("share.allowUnauthenticatedShares");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(
|
||||||
config.get("JWT_SECRET");
|
config: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
config.get("internal.jwtSecret");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: JwtStrategy.extractJWT,
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
secretOrKey: config.get("JWT_SECRET"),
|
secretOrKey: config.get("internal.jwtSecret"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
FileTypeValidator,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseFilePipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
@@ -7,37 +21,36 @@ import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
|||||||
import { ConfigDTO } from "./dto/config.dto";
|
import { ConfigDTO } from "./dto/config.dto";
|
||||||
import { TestEmailDTO } from "./dto/testEmail.dto";
|
import { TestEmailDTO } from "./dto/testEmail.dto";
|
||||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||||
|
import { LogoService } from "./logo.service";
|
||||||
|
|
||||||
@Controller("configs")
|
@Controller("configs")
|
||||||
export class ConfigController {
|
export class ConfigController {
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private emailService: EmailService
|
private logoService: LogoService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@SkipThrottle()
|
||||||
async list() {
|
async list() {
|
||||||
return new ConfigDTO().fromList(await this.configService.list());
|
return new ConfigDTO().fromList(await this.configService.list());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("admin")
|
@Get("admin/:category")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async listForAdmin() {
|
async getByCategory(@Param("category") category: string) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.listForAdmin()
|
await this.configService.getByCategory(category),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("admin")
|
@Patch("admin")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
await this.configService.updateMany(data);
|
return new AdminConfigDTO().fromList(
|
||||||
}
|
await this.configService.updateMany(data),
|
||||||
|
);
|
||||||
@Post("admin/finishSetup")
|
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
|
||||||
async finishSetup() {
|
|
||||||
return await this.configService.changeSetupStatus("FINISHED");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("admin/testEmail")
|
@Post("admin/testEmail")
|
||||||
@@ -45,4 +58,18 @@ export class ConfigController {
|
|||||||
async testEmail(@Body() { email }: TestEmailDTO) {
|
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||||
await this.emailService.sendTestMail(email);
|
await this.emailService.sendTestMail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("admin/logo")
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async uploadLogo(
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return await this.logoService.create(file.buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -11,75 +11,100 @@ 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): any {
|
get(key: `${string}.${string}`): any {
|
||||||
const configVariable = this.configVariables.filter(
|
const configVariable = this.configVariables.filter(
|
||||||
(variable) => variable.key == key
|
(variable) => `${variable.category}.${variable.name}` == key,
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||||
|
|
||||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
const value = configVariable.value ?? configVariable.defaultValue;
|
||||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
|
||||||
|
if (configVariable.type == "number") return parseInt(value);
|
||||||
|
if (configVariable.type == "boolean") return value == "true";
|
||||||
if (configVariable.type == "string" || configVariable.type == "text")
|
if (configVariable.type == "string" || configVariable.type == "text")
|
||||||
return configVariable.value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listForAdmin() {
|
async getByCategory(category: string) {
|
||||||
return await this.prisma.config.findMany({
|
const configVariables = await this.prisma.config.findMany({
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
where: { locked: { equals: false } },
|
where: { category, locked: { equals: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return configVariables.map((variable) => {
|
||||||
|
return {
|
||||||
|
...variable,
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
value: variable.value ?? variable.defaultValue,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.config.findMany({
|
const configVariables = await this.prisma.config.findMany({
|
||||||
where: { secret: { equals: false } },
|
where: { secret: { equals: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return configVariables.map((variable) => {
|
||||||
|
return {
|
||||||
|
...variable,
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
value: variable.value ?? variable.defaultValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||||
|
const response: Config[] = [];
|
||||||
|
|
||||||
for (const variable of data) {
|
for (const variable of data) {
|
||||||
await this.update(variable.key, variable.value);
|
response.push(await this.update(variable.key, variable.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(key: string, value: string | number | boolean) {
|
async update(key: string, value: string | number | boolean) {
|
||||||
const configVariable = await this.prisma.config.findUnique({
|
const configVariable = await this.prisma.config.findUnique({
|
||||||
where: { key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
category: key.split(".")[0],
|
||||||
|
name: key.split(".")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configVariable || configVariable.locked)
|
if (!configVariable || configVariable.locked)
|
||||||
throw new NotFoundException("Config variable not found");
|
throw new NotFoundException("Config variable not found");
|
||||||
|
|
||||||
if (
|
if (value === "") {
|
||||||
|
value = null;
|
||||||
|
} else if (
|
||||||
typeof value != configVariable.type &&
|
typeof value != configVariable.type &&
|
||||||
typeof value == "string" &&
|
typeof value == "string" &&
|
||||||
configVariable.type != "text"
|
configVariable.type != "text"
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Config variable must be of type ${configVariable.type}`
|
`Config variable must be of type ${configVariable.type}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedVariable = await this.prisma.config.update({
|
const updatedVariable = await this.prisma.config.update({
|
||||||
where: { key },
|
where: {
|
||||||
data: { value: value.toString() },
|
name_category: {
|
||||||
|
category: key.split(".")[0],
|
||||||
|
name: key.split(".")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { value: value === null ? null : value.toString() },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.configVariables = await this.prisma.config.findMany();
|
this.configVariables = await this.prisma.config.findMany();
|
||||||
|
|
||||||
return updatedVariable;
|
return updatedVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
|
|
||||||
return await this.prisma.config.update({
|
|
||||||
where: { key: "SETUP_STATUS" },
|
|
||||||
data: { value: status },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { Expose, plainToClass } from "class-transformer";
|
|||||||
import { ConfigDTO } from "./config.dto";
|
import { ConfigDTO } from "./config.dto";
|
||||||
|
|
||||||
export class AdminConfigDTO extends ConfigDTO {
|
export class AdminConfigDTO extends ConfigDTO {
|
||||||
|
@Expose()
|
||||||
|
name: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
defaultValue: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
category: string;
|
|
||||||
|
|
||||||
from(partial: Partial<AdminConfigDTO>) {
|
from(partial: Partial<AdminConfigDTO>) {
|
||||||
return plainToClass(AdminConfigDTO, partial, {
|
return plainToClass(AdminConfigDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
backend/src/config/logo.service.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as sharp from "sharp";
|
||||||
|
|
||||||
|
const IMAGES_PATH = "../frontend/public/img";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
async create(file: Buffer) {
|
||||||
|
const resized = await sharp(file).resize(900).toBuffer();
|
||||||
|
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
||||||
|
this.createFavicon(file);
|
||||||
|
this.createPWAIcons(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFavicon(file: Buffer) {
|
||||||
|
const resized = await sharp(file).resize(16).toBuffer();
|
||||||
|
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPWAIcons(file: Buffer) {
|
||||||
|
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
const resized = await sharp(file).resize(size).toBuffer();
|
||||||
|
fs.promises.writeFile(
|
||||||
|
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||||
|
resized,
|
||||||
|
"binary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/constants.ts
Normal file
@@ -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;
|
||||||
@@ -1,74 +1,133 @@
|
|||||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import * as moment from "moment";
|
||||||
import * as nodemailer from "nodemailer";
|
import * as nodemailer from "nodemailer";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
|
||||||
getTransporter() {
|
getTransporter() {
|
||||||
if (!this.config.get("SMTP_ENABLED"))
|
if (!this.config.get("smtp.enabled"))
|
||||||
throw new InternalServerErrorException("SMTP is disabled");
|
throw new InternalServerErrorException("SMTP is disabled");
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: this.config.get("SMTP_HOST"),
|
host: this.config.get("smtp.host"),
|
||||||
port: parseInt(this.config.get("SMTP_PORT")),
|
port: this.config.get("smtp.port"),
|
||||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
secure: this.config.get("smtp.port") == 465,
|
||||||
auth: {
|
auth: {
|
||||||
user: this.config.get("SMTP_USERNAME"),
|
user: this.config.get("smtp.username"),
|
||||||
pass: this.config.get("SMTP_PASSWORD"),
|
pass: this.config.get("smtp.password"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async 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("ENABLE_SHARE_EMAIL_RECIPIENTS"))
|
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
recipientEmail,
|
||||||
to: recipientEmail,
|
this.config.get("email.shareRecipientsSubject"),
|
||||||
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
|
this.config
|
||||||
text: this.config
|
.get("email.shareRecipientsMessage")
|
||||||
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
|
|
||||||
.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("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
recipientEmail,
|
||||||
to: recipientEmail,
|
this.config.get("email.reverseShareSubject"),
|
||||||
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
|
this.config
|
||||||
text: this.config
|
.get("email.reverseShareMessage")
|
||||||
.get("REVERSE_SHARE_EMAIL_MESSAGE")
|
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||||
|
const resetPasswordUrl = `${this.config.get(
|
||||||
|
"general.appUrl",
|
||||||
|
)}/auth/resetPassword/${token}`;
|
||||||
|
|
||||||
|
await this.sendMail(
|
||||||
|
recipientEmail,
|
||||||
|
this.config.get("email.resetPasswordSubject"),
|
||||||
|
this.config
|
||||||
|
.get("email.resetPasswordMessage")
|
||||||
|
.replaceAll("\\n", "\n")
|
||||||
|
.replaceAll("{url}", resetPasswordUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||||
|
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||||
|
|
||||||
|
await this.sendMail(
|
||||||
|
recipientEmail,
|
||||||
|
this.config.get("email.inviteSubject"),
|
||||||
|
this.config
|
||||||
|
.get("email.inviteMessage")
|
||||||
|
.replaceAll("{url}", loginUrl)
|
||||||
|
.replaceAll("{password}", password),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendTestMail(recipientEmail: string) {
|
||||||
try {
|
await this.getTransporter()
|
||||||
await this.getTransporter().sendMail({
|
.sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,35 +62,35 @@ export class FileService {
|
|||||||
// Check if share size limit is exceeded
|
// Check if share size limit is exceeded
|
||||||
const fileSizeSum = share.files.reduce(
|
const fileSizeSum = share.files.reduce(
|
||||||
(n, { size }) => n + parseInt(size),
|
(n, { size }) => n + parseInt(size),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
|
shareSizeSum > this.config.get("share.maxSize") ||
|
||||||
(share.reverseShare?.maxShareSize &&
|
(share.reverseShare?.maxShareSize &&
|
||||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Max share size exceeded",
|
"Max share size exceeded",
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
buffer
|
buffer,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLastChunk = chunk.index == chunk.total - 1;
|
const isLastChunk = chunk.index == chunk.total - 1;
|
||||||
if (isLastChunk) {
|
if (isLastChunk) {
|
||||||
fs.renameSync(
|
fs.renameSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
`./data/uploads/shares/${shareId}/${file.id}`
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||||
);
|
);
|
||||||
const fileSize = fs.statSync(
|
const fileSize = fs.statSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}`
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||||
).size;
|
).size;
|
||||||
await this.prisma.file.create({
|
await this.prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { FileModule } from "src/file/file.module";
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||||
import { JobsService } from "./jobs.service";
|
import { JobsService } from "./jobs.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FileModule],
|
imports: [FileModule, ReverseShareModule],
|
||||||
providers: [JobsService],
|
providers: [JobsService],
|
||||||
})
|
})
|
||||||
export class JobsModule {}
|
export class JobsModule {}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
|
import { SHARE_DIRECTORY } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobsService {
|
export class JobsService {
|
||||||
|
private readonly logger = new Logger(JobsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private fileService: FileService
|
private reverseShareService: ReverseShareService,
|
||||||
|
private fileService: FileService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
@@ -32,8 +37,28 @@ export class JobsService {
|
|||||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expiredShares.length > 0)
|
if (expiredShares.length > 0) {
|
||||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron("0 * * * *")
|
||||||
|
async deleteExpiredReverseShares() {
|
||||||
|
const expiredReverseShares = await this.prisma.reverseShare.findMany({
|
||||||
|
where: {
|
||||||
|
shareExpiration: { lt: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const expiredReverseShare of expiredReverseShares) {
|
||||||
|
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredReverseShares.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 0 * * *")
|
@Cron("0 0 * * *")
|
||||||
@@ -41,42 +66,54 @@ export class JobsService {
|
|||||||
let filesDeleted = 0;
|
let filesDeleted = 0;
|
||||||
|
|
||||||
const shareDirectories = fs
|
const shareDirectories = fs
|
||||||
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
|
||||||
.filter((dirent) => dirent.isDirectory())
|
.filter((dirent) => dirent.isDirectory())
|
||||||
.map((dirent) => dirent.name);
|
.map((dirent) => dirent.name);
|
||||||
|
|
||||||
for (const shareDirectory of shareDirectories) {
|
for (const shareDirectory of shareDirectories) {
|
||||||
const temporaryFiles = fs
|
const temporaryFiles = fs
|
||||||
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
|
||||||
.filter((file) => file.endsWith(".tmp-chunk"));
|
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||||
|
|
||||||
for (const file of temporaryFiles) {
|
for (const file of temporaryFiles) {
|
||||||
const stats = fs.statSync(
|
const stats = fs.statSync(
|
||||||
`./data/uploads/shares/${shareDirectory}/${file}`
|
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||||
);
|
);
|
||||||
const isOlderThanOneDay = moment(stats.mtime)
|
const isOlderThanOneDay = moment(stats.mtime)
|
||||||
.add(1, "day")
|
.add(1, "day")
|
||||||
.isBefore(moment());
|
.isBefore(moment());
|
||||||
|
|
||||||
if (isOlderThanOneDay) {
|
if (isOlderThanOneDay) {
|
||||||
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
|
||||||
filesDeleted++;
|
filesDeleted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`job: deleted ${filesDeleted} temporary files`);
|
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
async deleteExpiredRefreshTokens() {
|
async deleteExpiredTokens() {
|
||||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
const { count: refreshTokenCount } =
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { expiresAt: { lt: new Date() } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { count: loginTokenCount } = await this.prisma.loginToken.deleteMany({
|
||||||
where: { expiresAt: { lt: new Date() } },
|
where: { expiresAt: { lt: new Date() } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expiredRefreshTokens.count > 0)
|
const { count: resetPasswordTokenCount } =
|
||||||
console.log(
|
await this.prisma.resetPasswordToken.deleteMany({
|
||||||
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens`
|
where: { expiresAt: { lt: new Date() } },
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const deletedTokensCount =
|
||||||
|
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||||
|
|
||||||
|
if (deletedTokensCount > 0) {
|
||||||
|
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import * as bodyParser from "body-parser";
|
import * as bodyParser from "body-parser";
|
||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
import { DATA_DIRECTORY } from "./constants";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
@@ -15,9 +17,22 @@ async function bootstrap() {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix("api");
|
app.setGlobalPrefix("api");
|
||||||
await app.listen(8080);
|
|
||||||
|
// Setup Swagger in development mode
|
||||||
|
if (process.env.NODE_ENV == "development") {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle("Pingvin Share API")
|
||||||
|
.setVersion("1.0")
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup("api/swagger", app, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsBoolean, IsString } from "class-validator";
|
import { IsBoolean, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
export class CreateReverseShareDTO {
|
export class CreateReverseShareDTO {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -9,4 +9,8 @@ export class CreateReverseShareDTO {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
shareExpiration: string;
|
shareExpiration: string;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
maxUseCount: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { OmitType } from "@nestjs/mapped-types";
|
import { OmitType } from "@nestjs/swagger";
|
||||||
import { Expose, plainToClass, Type } from "class-transformer";
|
import { Expose, plainToClass, Type } from "class-transformer";
|
||||||
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
||||||
import { ReverseShareDTO } from "./reverseShare.dto";
|
import { ReverseShareDTO } from "./reverseShare.dto";
|
||||||
|
|
||||||
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
|
export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||||
"shareExpiration",
|
"shareExpiration",
|
||||||
] as const) {
|
] as const) {
|
||||||
@Expose()
|
@Expose()
|
||||||
@@ -11,16 +11,19 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
|
|||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||||
share: Omit<
|
shares: Omit<
|
||||||
MyShareDTO,
|
MyShareDTO,
|
||||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||||
>;
|
>[];
|
||||||
|
|
||||||
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
@Expose()
|
||||||
|
remainingUses: number;
|
||||||
|
|
||||||
|
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ReverseShareTokenWithShare, part, {
|
plainToClass(ReverseShareTokenWithShares, part, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import { JwtGuard } from "src/auth/guard/jwt.guard";
|
|||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||||
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
||||||
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare";
|
import { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
|
||||||
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
||||||
import { ReverseShareService } from "./reverseShare.service";
|
import { ReverseShareService } from "./reverseShare.service";
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -31,7 +31,7 @@ export class ReverseShareController {
|
|||||||
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
||||||
const token = await this.reverseShareService.create(body, user.id);
|
const token = await this.reverseShareService.create(body, user.id);
|
||||||
|
|
||||||
const link = `${this.config.get("APP_URL")}/upload/${token}`;
|
const link = `${this.config.get("general.appUrl")}/upload/${token}`;
|
||||||
|
|
||||||
return { token, link };
|
return { token, link };
|
||||||
}
|
}
|
||||||
@@ -44,15 +44,15 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getAllByUser(@GetUser() user: User) {
|
async getAllByUser(@GetUser() user: User) {
|
||||||
return new ReverseShareTokenWithShare().fromList(
|
return new ReverseShareTokenWithShares().fromList(
|
||||||
await this.reverseShareService.getAllByUser(user.id)
|
await this.reverseShareService.getAllByUser(user.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 +19,22 @@ 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();
|
||||||
|
|
||||||
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
|
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||||
|
|
||||||
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({
|
||||||
data: {
|
data: {
|
||||||
shareExpiration: expirationDate,
|
shareExpiration: expirationDate,
|
||||||
|
remainingUses: data.maxUseCount,
|
||||||
maxShareSize: data.maxShareSize,
|
maxShareSize: data.maxShareSize,
|
||||||
sendEmailNotification: data.sendEmailNotification,
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
creatorId,
|
creatorId,
|
||||||
@@ -43,7 +44,9 @@ export class ReverseShareService {
|
|||||||
return reverseShare.token;
|
return reverseShare.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByToken(reverseShareToken: string) {
|
async getByToken(reverseShareToken?: string) {
|
||||||
|
if (!reverseShareToken) return null;
|
||||||
|
|
||||||
const reverseShare = await this.prisma.reverseShare.findUnique({
|
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
});
|
});
|
||||||
@@ -60,7 +63,7 @@ export class ReverseShareService {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
shareExpiration: "desc",
|
shareExpiration: "desc",
|
||||||
},
|
},
|
||||||
include: { share: { include: { creator: true } } },
|
include: { shares: { include: { creator: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reverseShares;
|
return reverseShares;
|
||||||
@@ -74,21 +77,21 @@ export class ReverseShareService {
|
|||||||
if (!reverseShare) return false;
|
if (!reverseShare) return false;
|
||||||
|
|
||||||
const isExpired = new Date() > reverseShare.shareExpiration;
|
const isExpired = new Date() > reverseShare.shareExpiration;
|
||||||
const isUsed = reverseShare.used;
|
const remainingUsesExceeded = reverseShare.remainingUses <= 0;
|
||||||
|
|
||||||
return !(isExpired || isUsed);
|
return !(isExpired || remainingUsesExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
const share = await this.prisma.share.findFirst({
|
const shares = await this.prisma.share.findMany({
|
||||||
where: { reverseShare: { id } },
|
where: { reverseShare: { id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (share) {
|
for (const share of shares) {
|
||||||
await this.prisma.share.delete({ where: { id: share.id } });
|
await this.prisma.share.delete({ where: { id: share.id } });
|
||||||
await this.fileService.deleteAllFiles(share.id);
|
await this.fileService.deleteAllFiles(share.id);
|
||||||
} else {
|
|
||||||
await this.prisma.reverseShare.delete({ where: { id } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.prisma.reverseShare.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -44,12 +45,11 @@ export class ShareService {
|
|||||||
let expirationDate: Date;
|
let expirationDate: Date;
|
||||||
|
|
||||||
// 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
|
||||||
if (reverseShareToken) {
|
const reverseShare = await this.reverseShareService.getByToken(
|
||||||
const { shareExpiration } = await this.reverseShareService.getByToken(
|
reverseShareToken,
|
||||||
reverseShareToken
|
);
|
||||||
);
|
if (reverseShare) {
|
||||||
|
expirationDate = reverseShare.shareExpiration;
|
||||||
expirationDate = shareExpiration;
|
|
||||||
} else {
|
} else {
|
||||||
// We have to add an exception for "never" (since moment won't like that)
|
// We have to add an exception for "never" (since moment won't like that)
|
||||||
if (share.expiration !== "never") {
|
if (share.expiration !== "never") {
|
||||||
@@ -57,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 {
|
||||||
@@ -66,7 +66,7 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,12 +84,14 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (reverseShareToken) {
|
if (reverseShare) {
|
||||||
// Assign share to reverse share token
|
// Assign share to reverse share token
|
||||||
await this.prisma.reverseShare.update({
|
await this.prisma.reverseShare.update({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
data: {
|
data: {
|
||||||
shareId: share.id,
|
shares: {
|
||||||
|
connect: { id: shareTuple.id },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,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`);
|
||||||
|
|
||||||
@@ -132,46 +134,48 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
share.reverseShare &&
|
share.reverseShare &&
|
||||||
this.config.get("SMTP_ENABLED") &&
|
this.config.get("smtp.enabled") &&
|
||||||
share.reverseShare.sendEmailNotification
|
share.reverseShare.sendEmailNotification
|
||||||
) {
|
) {
|
||||||
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 (reverseShareToken) {
|
if (share.reverseShare) {
|
||||||
await this.prisma.reverseShare.update({
|
await this.prisma.reverseShare.update({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
data: { used: true },
|
data: { remainingUses: { decrement: 1 } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.prisma.share.update({
|
return this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { uploadLocked: true },
|
data: { uploadLocked: true },
|
||||||
});
|
});
|
||||||
@@ -191,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> {
|
||||||
@@ -221,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +304,8 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +316,7 @@ export class ShareService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const claims = this.jwtService.verify(token, {
|
const claims = this.jwtService.verify(token, {
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
// Ignore expiration if expiration is 0
|
// Ignore expiration if expiration is 0
|
||||||
ignoreExpiration: moment(expiration).isSame(0),
|
ignoreExpiration: moment(expiration).isSame(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Expose, plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { Allow } from "class-validator";
|
import { Allow, IsOptional, MinLength } from "class-validator";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class CreateUserDTO extends UserDTO {
|
export class CreateUserDTO extends UserDTO {
|
||||||
@Expose()
|
|
||||||
@Allow()
|
@Allow()
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@MinLength(8)
|
||||||
|
@IsOptional()
|
||||||
|
password: string;
|
||||||
|
|
||||||
from(partial: Partial<CreateUserDTO>) {
|
from(partial: Partial<CreateUserDTO>) {
|
||||||
return plainToClass(CreateUserDTO, partial, {
|
return plainToClass(CreateUserDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OmitType, PartialType } from "@nestjs/mapped-types";
|
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),
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
import { PartialType } from "@nestjs/swagger";
|
||||||
import { CreateUserDTO } from "./createUser.dto";
|
import { CreateUserDTO } from "./createUser.dto";
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { Response } from "express";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
@@ -33,14 +35,23 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete("me")
|
@Delete("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async deleteCurrentUser(@GetUser() user: User) {
|
async deleteCurrentUser(
|
||||||
|
@GetUser() user: User,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
|
response.cookie("refresh_token", "", {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
return new UserDTO().from(await this.userService.delete(user.id));
|
return new UserDTO().from(await this.userService.delete(user.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { UserController } from "./user.controller";
|
import { UserController } from "./user.controller";
|
||||||
import { UserSevice } from "./user.service";
|
import { UserSevice } from "./user.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [EmailModule],
|
||||||
providers: [UserSevice],
|
providers: [UserSevice],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
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 { 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";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private emailService: EmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.user.findMany();
|
return await this.prisma.user.findMany();
|
||||||
@@ -19,7 +23,17 @@ export class UserSevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateUserDTO) {
|
async create(dto: CreateUserDTO) {
|
||||||
const hash = await argon.hash(dto.password);
|
let hash: string;
|
||||||
|
|
||||||
|
// The password can be undefined if the user is invited by an admin
|
||||||
|
if (!dto.password) {
|
||||||
|
const randomPassword = crypto.randomUUID();
|
||||||
|
hash = await argon.hash(randomPassword);
|
||||||
|
await this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||||
|
} else {
|
||||||
|
hash = await argon.hash(dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.user.create({
|
return await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -32,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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
|
"_postman_id": "cd31bdf9-d558-42da-9231-154721476cd2",
|
||||||
"name": "Pingvin Share Testing",
|
"name": "Pingvin Share Testing",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
"_exporter_id": "17822132"
|
"_exporter_id": "17822132"
|
||||||
@@ -804,16 +804,6 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -853,16 +843,6 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -987,7 +967,8 @@
|
|||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"pm.collectionVariables.set(\"shareToken\", pm.response.json().token)"
|
"pm.collectionVariables.set(\"COOKIES\", `${pm.collectionVariables.get(\"COOKIES\")};${pm.response.headers.get(\"Set-Cookie\")}`)",
|
||||||
|
""
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
}
|
}
|
||||||
@@ -1041,8 +1022,6 @@
|
|||||||
" pm.expect(responseBody.files.length).be.equal(2)",
|
" pm.expect(responseBody.files.length).be.equal(2)",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
|
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
@@ -1051,13 +1030,7 @@
|
|||||||
],
|
],
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [
|
"header": [],
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId",
|
"raw": "{{API_URL}}/shares/:shareId",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -1077,88 +1050,6 @@
|
|||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Get file download url",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"let URL = require('url');",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Status code is 200\", () => {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Response body correct\", () => {",
|
|
||||||
" const responseBody = pm.response.json();",
|
|
||||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"fileDownloadPath\",path )"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
":fileId",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fileId",
|
|
||||||
"value": "{{fileId}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Get File",
|
"name": "Get File",
|
||||||
"event": [
|
"event": [
|
||||||
@@ -1174,97 +1065,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/{{fileDownloadPath}}",
|
"raw": "{{API_URL}}/shares/:shareId/files/{{fileId}}",
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"{{fileDownloadPath}}"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get zip download url",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"let URL = require('url');",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Status code is 200\", () => {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Response body correct\", () => {",
|
|
||||||
" const responseBody = pm.response.json();",
|
|
||||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"zipDownloadPath\",path )"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
@@ -1272,8 +1077,7 @@
|
|||||||
"shares",
|
"shares",
|
||||||
":shareId",
|
":shareId",
|
||||||
"files",
|
"files",
|
||||||
"zip",
|
"{{fileId}}"
|
||||||
"download"
|
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
@@ -1306,64 +1110,16 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/{{zipDownloadPath}}",
|
"raw": "{{API_URL}}/shares/:shareId/files/zip",
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"{{zipDownloadPath}}"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Negative",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Get share - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId",
|
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
"path": [
|
"path": [
|
||||||
"shares",
|
"shares",
|
||||||
":shareId"
|
":shareId",
|
||||||
|
"files",
|
||||||
|
"zip"
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
@@ -1374,7 +1130,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Negative",
|
||||||
|
"item": [
|
||||||
{
|
{
|
||||||
"name": "Get share token - Wrong password",
|
"name": "Get share token - Wrong password",
|
||||||
"event": [
|
"event": [
|
||||||
@@ -1468,128 +1229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get file download url - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
":fileId",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fileId",
|
|
||||||
"value": "{{fileId}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get zip download url - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
"zip",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
4
crowdin.yml
Normal 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"
|
||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/opt/app/backend/data"
|
- "./data:/opt/app/backend/data"
|
||||||
|
- "./data/images:/opt/app/frontend/public/img"
|
||||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# clamav:
|
# clamav:
|
||||||
|
|||||||
95
docs/CONTRIBUTING.es.md
Normal 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.
|
||||||
97
docs/CONTRIBUTING.zh-cn.md
Normal 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
@@ -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
@@ -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) 来提交你的贡献
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"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 }],
|
||||||
"react-hooks/exhaustive-deps": ["off"],
|
"react-hooks/exhaustive-deps": ["off"],
|
||||||
"import/no-anonymous-default-export": ["off"],
|
"import/no-anonymous-default-export": ["off"],
|
||||||
"no-unused-vars": ["warn"],
|
"no-unused-vars": ["warn"],
|
||||||
"react/no-unescaped-entities": ["off"]
|
"react/no-unescaped-entities": ["off"],
|
||||||
|
"@next/next/no-img-element": ["off"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/src/i18n/translations/*
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3567
frontend/package-lock.json
generated
@@ -1,50 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.18.2",
|
||||||
"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",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/img/logo.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1018 B |