Compare commits
242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f9a502e40 | ||
|
|
def1d571d0 | ||
|
|
58de3ce0df | ||
|
|
dd546b4f17 | ||
|
|
19e6b9a0be | ||
|
|
85f514316b | ||
|
|
cfdb29ed4d | ||
|
|
18b34e9bf2 | ||
|
|
9d603c41e5 | ||
|
|
7ba499d5a0 | ||
|
|
3d1656688e | ||
|
|
e53a25750d | ||
|
|
ffcd857d9a | ||
|
|
48a6ceb3b4 | ||
|
|
84833e5f91 | ||
|
|
1864951bdb | ||
|
|
427e99c7b1 | ||
|
|
ccc783ab6a | ||
|
|
853f217bf1 | ||
|
|
af047c0bc1 | ||
|
|
8ddee32664 | ||
|
|
e7128dcfef | ||
|
|
56d8517073 | ||
|
|
df4341163a | ||
|
|
81547ba1e0 | ||
|
|
a790ac73fd | ||
|
|
3fd689a760 | ||
|
|
6a8ac1aa65 | ||
|
|
4d1ce8aa49 | ||
|
|
71eab23503 | ||
|
|
2943073c10 | ||
|
|
27fca64a69 | ||
|
|
7d005db4e1 | ||
|
|
73a76a9d5b | ||
|
|
8e828311fe | ||
|
|
b7410d8589 | ||
|
|
5cfa8f3015 | ||
|
|
ccb7fdca43 | ||
|
|
ac81cb9ab7 | ||
|
|
b737cba35e | ||
|
|
1d51973358 | ||
|
|
589127e943 | ||
|
|
6722938ae6 | ||
|
|
9f720388ef | ||
|
|
e7b3c48ff4 | ||
|
|
0dfd4d014d | ||
|
|
ce0dc976a8 | ||
|
|
61418a1d8d | ||
|
|
1159d972a8 | ||
|
|
b6d1720fe6 | ||
|
|
dc30f4f3c9 | ||
|
|
e77edfd5d3 | ||
|
|
e848675d63 | ||
|
|
5e2d44b423 | ||
|
|
9dfb52a145 | ||
|
|
f4291421b5 | ||
|
|
28fdbc2281 | ||
|
|
2f74c87d0b | ||
|
|
dcea5ccd89 | ||
|
|
bfbc87817f | ||
|
|
a2e031326e | ||
|
|
ec92e85c8d | ||
|
|
4b642f419b | ||
|
|
75cd3895d9 | ||
|
|
d3b38b27d1 | ||
|
|
398a5532dc | ||
|
|
70b577f5ac | ||
|
|
0d71146a2c | ||
|
|
a53f0711fb | ||
|
|
8a7db6bf97 | ||
|
|
f78777b284 | ||
|
|
3a534c7512 | ||
|
|
2b7d3c0a8a | ||
|
|
7fea358410 | ||
|
|
f65c1ef7d8 | ||
|
|
9e4496dc03 | ||
|
|
235772a54b | ||
|
|
784e80db5b | ||
|
|
0442ece9ba | ||
|
|
7f9f8b6fe7 | ||
|
|
bf1b2633c8 | ||
|
|
b3ea96c191 | ||
|
|
4a7076a094 | ||
|
|
0c62485833 | ||
|
|
2c555eaf9f | ||
|
|
36afbf91b7 | ||
|
|
df1ffaa2bc | ||
|
|
53c05518df | ||
|
|
b58dcdba0b | ||
|
|
4d3aa398a2 | ||
|
|
a120d44185 | ||
|
|
362e7d4f38 | ||
|
|
f36ba8ac0a | ||
|
|
30caeb5b25 | ||
|
|
bfd4049c15 | ||
|
|
856c54d5d6 | ||
|
|
6a97cc279c | ||
|
|
7e09ae1f98 | ||
|
|
3946f6f237 | ||
|
|
5069abe4b9 | ||
|
|
5a54fe4cb7 | ||
|
|
0b406f0464 | ||
|
|
cbc7fd83a7 | ||
|
|
c178a83fa5 | ||
|
|
185f1b2ab7 | ||
|
|
6771bfdf50 | ||
|
|
2db1f6a112 | ||
|
|
168038eae7 | ||
|
|
3df80acff9 | ||
|
|
e86f93830b | ||
|
|
38f1626b11 | ||
|
|
ac9b0a1d53 | ||
|
|
ba2e7e122c | ||
|
|
3527dd1dd9 | ||
|
|
54af6c2055 | ||
|
|
3160f90e1d | ||
|
|
da54ce6ee0 | ||
|
|
468b25828b | ||
|
|
9d4bb55a09 | ||
|
|
f78ffd69e7 | ||
|
|
17528f999a | ||
|
|
c8f05f2475 | ||
|
|
424e2564d5 | ||
|
|
18d8cbbbab | ||
|
|
c7dacb26e8 | ||
|
|
b6d98c7c42 | ||
|
|
c52ec71920 | ||
|
|
6cf5c66fe2 | ||
|
|
51478b6a9f | ||
|
|
6f45c3b1fb | ||
|
|
ff2dd81055 | ||
|
|
c26de4e881 | ||
|
|
4ef7ebb062 | ||
|
|
d870b5721a | ||
|
|
d8084e401d | ||
|
|
e1a5d19544 | ||
|
|
4ce64206be | ||
|
|
77eef187b7 | ||
|
|
c7138bcf5d | ||
|
|
ab4f19e921 | ||
|
|
428c1d2b99 | ||
|
|
c89ca7e64b | ||
|
|
297e8c0ab1 | ||
|
|
446f9dd209 | ||
|
|
acbff6e129 | ||
|
|
546d2c1ce4 | ||
|
|
37839e6b18 | ||
|
|
0b355b94c4 | ||
|
|
6444a9d553 | ||
|
|
08079744a0 | ||
|
|
558dd2fb15 | ||
|
|
fe085b58a5 | ||
|
|
958b79d787 | ||
|
|
ede9c2a816 | ||
|
|
e195565630 | ||
|
|
520f9abcf7 | ||
|
|
bfbe8de98a | ||
|
|
d5cd3002a1 | ||
|
|
77a092a3cf | ||
|
|
613bae9033 | ||
|
|
2e692241c5 | ||
|
|
1e96011793 | ||
|
|
522a041ca1 | ||
|
|
ce6430da9f | ||
|
|
2b3ce3ffd2 | ||
|
|
104cc06145 | ||
|
|
4a50a5aa3b | ||
|
|
d6b8b56247 | ||
|
|
5883dff4cf | ||
|
|
511ae933fa | ||
|
|
df2521b192 | ||
|
|
8f16d6b53e | ||
|
|
3310fe53b3 | ||
|
|
adc4af996d | ||
|
|
61edc4f4f6 | ||
|
|
eba7984a0f | ||
|
|
69752b8b41 | ||
|
|
ee73293c0f | ||
|
|
5553607ffe | ||
|
|
2ca6e6ee5f | ||
|
|
18135b0ec0 | ||
|
|
f8bfb8ec3c | ||
|
|
187911e334 | ||
|
|
64acae11a2 | ||
|
|
6b39adfd03 | ||
|
|
d9cfe697d6 | ||
|
|
67a0fc6ea5 | ||
|
|
b13a81a88c | ||
|
|
97dc3ecfdd | ||
|
|
d00d52baa9 | ||
|
|
4c8848a2d9 | ||
|
|
3c8500008d | ||
|
|
325122b802 | ||
|
|
7dc2e56fee | ||
|
|
8b3e28bac8 | ||
|
|
347026b6d3 | ||
|
|
5a204d38a4 | ||
|
|
2eeb858f36 | ||
|
|
67faa860da | ||
|
|
beca26871d | ||
|
|
15d1756a4e | ||
|
|
be202d3d41 | ||
|
|
f0e785b1a2 | ||
|
|
92e1e82e09 | ||
|
|
0670aaa331 | ||
|
|
10b71e7035 | ||
|
|
dee70987eb | ||
|
|
3d2b978daf | ||
|
|
e813da05ae | ||
|
|
1fba0fd546 | ||
|
|
96cd353669 | ||
|
|
3e0735c620 | ||
|
|
d05988f281 | ||
|
|
42a985be04 | ||
|
|
af472af3bb | ||
|
|
f53f71f054 | ||
|
|
5622f9eb2f | ||
|
|
02b9abf6c5 | ||
|
|
6a4c3bf58f | ||
|
|
64efac5b68 | ||
|
|
8c5c696c51 | ||
|
|
01da83cdf6 | ||
|
|
cfcc5cebac | ||
|
|
b96878b6b1 | ||
|
|
9c381a2ed6 | ||
|
|
4f9b4f38f6 | ||
|
|
c98b237259 | ||
|
|
17d593a794 | ||
|
|
ac580b79b4 | ||
|
|
4186a768b3 | ||
|
|
4924f76394 | ||
|
|
f1f514dff7 | ||
|
|
94e2a6110d | ||
|
|
7716f5c0ce | ||
|
|
0a963bfaf1 | ||
|
|
472c93d548 | ||
|
|
93aacca9b4 | ||
|
|
3505669135 | ||
|
|
fe735f9704 | ||
|
|
3563715f57 | ||
|
|
14c2185e6f | ||
|
|
27ee9fb6cb |
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
9
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -31,14 +31,13 @@ body:
|
||||
label: "👎 Actual Behavior"
|
||||
description: "What did actually happen? Add screenshots, if applicable."
|
||||
placeholder: "It actually ..."
|
||||
- type: input
|
||||
- type: textarea
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: "🌐 Browser"
|
||||
description: "Which browser do you use?"
|
||||
placeholder: "Firefox"
|
||||
label: "📜 Logs"
|
||||
description: "Paste any relevant logs here."
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
3
.github/workflows/backend-system-tests.yml
vendored
3
.github/workflows/backend-system-tests.yml
vendored
@@ -10,8 +10,9 @@ on:
|
||||
|
||||
jobs:
|
||||
system-tests:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18
|
||||
container: node:22
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Dependencies
|
||||
|
||||
29
.github/workflows/build-docker-image.yml
vendored
29
.github/workflows/build-docker-image.yml
vendored
@@ -6,29 +6,50 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,6 +7,9 @@ node_modules
|
||||
/frontend/.next/
|
||||
/frontend/out/
|
||||
|
||||
# yarn
|
||||
yarn.lock
|
||||
|
||||
# build
|
||||
build/
|
||||
dist/
|
||||
@@ -38,6 +41,10 @@ yarn-error.log*
|
||||
# project specific
|
||||
/backend/data/
|
||||
/data/
|
||||
/docs/build/
|
||||
/docs/.docusaurus
|
||||
/docs/.cache-loader
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains specific (webstorm)
|
||||
.idea/**/**
|
||||
|
||||
343
CHANGELOG.md
343
CHANGELOG.md
@@ -1,3 +1,346 @@
|
||||
## [1.13.0](https://github.com/stonith404/pingvin-share/compare/v1.12.0...v1.13.0) (2025-05-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow to use redis cache instead of memory cache ([#832](https://github.com/stonith404/pingvin-share/issues/832)) ([85f5143](https://github.com/stonith404/pingvin-share/commit/85f514316b0b808b8c063bf571df6b528a1b3de4))
|
||||
* **backend:** allow to define path to the config file ([#838](https://github.com/stonith404/pingvin-share/issues/838)) ([cfdb29e](https://github.com/stonith404/pingvin-share/commit/cfdb29ed4dde875233b4bc3f510ae50976b963b8))
|
||||
|
||||
## [1.12.0](https://github.com/stonith404/pingvin-share/compare/v1.11.1...v1.12.0) (2025-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **s3:** stream s3 content over a zip file ([#822](https://github.com/stonith404/pingvin-share/issues/822)) ([ccc783a](https://github.com/stonith404/pingvin-share/commit/ccc783ab6a00841a7041c454e77afb472d76999e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable HTML rendering in Markdown preview ([427e99c](https://github.com/stonith404/pingvin-share/commit/427e99c7b1d00ff6ed7b5fd879d8cf0f0d49281a))
|
||||
* health check for containers with reverse proxy disabled ([#816](https://github.com/stonith404/pingvin-share/issues/816)) ([a790ac7](https://github.com/stonith404/pingvin-share/commit/a790ac73fd42d266a957e09a05b1894199605f6a)), closes [#809](https://github.com/stonith404/pingvin-share/issues/809)
|
||||
* OIDC configuration from YAML configuration file doesn't get loaded ([48a6ceb](https://github.com/stonith404/pingvin-share/commit/48a6ceb3b4b4dfc0407dc6f9ee2e07cca1829cef))
|
||||
* spelling mistake and add clarity in email template ([#824](https://github.com/stonith404/pingvin-share/issues/824)) ([af047c0](https://github.com/stonith404/pingvin-share/commit/af047c0bc152a955b3ab135f5a9ea3d62b32fb0f))
|
||||
* use sandbox CSP for file previews ([1864951](https://github.com/stonith404/pingvin-share/commit/1864951bdbf573431e795109224a45545b86b54d))
|
||||
|
||||
## [1.11.1](https://github.com/stonith404/pingvin-share/compare/v1.11.0...v1.11.1) (2025-04-06)
|
||||
|
||||
## [1.11.0](https://github.com/stonith404/pingvin-share/compare/v1.10.4...v1.11.0) (2025-04-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add env variable to disable caddy ([#797](https://github.com/stonith404/pingvin-share/issues/797)) ([27fca64](https://github.com/stonith404/pingvin-share/commit/27fca64a69067eaa094d1559ca1fee4f064d89a7))
|
||||
* **s3:** allow disabling upload checksum ([#804](https://github.com/stonith404/pingvin-share/issues/804)) ([73a76a9](https://github.com/stonith404/pingvin-share/commit/73a76a9d5b9825a3dc396f49d76ddc5c303fce40))
|
||||
|
||||
## [1.10.4](https://github.com/stonith404/pingvin-share/compare/v1.10.3...v1.10.4) (2025-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin role gets reset if signing in with an OIDC provider ([ccb7fdc](https://github.com/stonith404/pingvin-share/commit/ccb7fdca43a2d458243e56a24510fe5325fa7942))
|
||||
|
||||
## [1.10.3](https://github.com/stonith404/pingvin-share/compare/v1.10.2...v1.10.3) (2025-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error while signing in with OIDC if roles claim is undefined ([b737cba](https://github.com/stonith404/pingvin-share/commit/b737cba35e59255904eccae9e9de1cbd36284fb1))
|
||||
|
||||
## [1.10.2](https://github.com/stonith404/pingvin-share/compare/v1.10.1...v1.10.2) (2025-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't throw error if group claim is missing ([e7b3c48](https://github.com/stonith404/pingvin-share/commit/e7b3c48ff48bd7cfb206c32ea97862b757057573))
|
||||
|
||||
## [1.10.1](https://github.com/stonith404/pingvin-share/compare/v1.10.0...v1.10.1) (2025-02-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin property can't be set if OAuth2 user email doesn't match actual user's email ([1159d97](https://github.com/stonith404/pingvin-share/commit/1159d972a8c32a0d6bf53d161c2fc09e6f8dfb28))
|
||||
* type error when trying to run the seed command ([b6d1720](https://github.com/stonith404/pingvin-share/commit/b6d1720fe637497ad624c6cdc40058b1b0f0c74c))
|
||||
|
||||
## [1.10.0](https://github.com/stonith404/pingvin-share/compare/v1.9.1...v1.10.0) (2025-02-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to configure application with a config file ([#740](https://github.com/stonith404/pingvin-share/issues/740)) ([9dfb52a](https://github.com/stonith404/pingvin-share/commit/9dfb52a14587065dacd9fcd2bb2efa1b458880a5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* confusing config configuration description for session duration ([28fdbc2](https://github.com/stonith404/pingvin-share/commit/28fdbc22814260040c78e27a62d86b84df83751f))
|
||||
* page crash if new release check fails ([e848675](https://github.com/stonith404/pingvin-share/commit/e848675d634a08efe3aac4e02d98136c36b36bfc))
|
||||
* smtp password gets autofilled in Firefox ([f429142](https://github.com/stonith404/pingvin-share/commit/f4291421b5531b0eeae5bcca9139f80c3cd43b4b))
|
||||
|
||||
## [1.9.1](https://github.com/stonith404/pingvin-share/compare/v1.9.0...v1.9.1) (2025-02-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* page doesn't reload on user deletion ([a2e0313](https://github.com/stonith404/pingvin-share/commit/a2e031326e51f7663c2d864dd0d08a65f180318e))
|
||||
* redirection to the OIDC end session endpoint ([ec92e85](https://github.com/stonith404/pingvin-share/commit/ec92e85c8d294b30117ad2599ad03b0bbb04574c))
|
||||
|
||||
## [1.9.0](https://github.com/stonith404/pingvin-share/compare/v1.8.2...v1.9.0) (2025-02-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to add email to recipients list by clicking enter ([#760](https://github.com/stonith404/pingvin-share/issues/760)) ([70b577f](https://github.com/stonith404/pingvin-share/commit/70b577f5ac8385cfc6a22ffee4c7e317e3cc6403))
|
||||
|
||||
## [1.8.2](https://github.com/stonith404/pingvin-share/compare/v1.8.1...v1.8.2) (2025-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* normal share gets attached to previously visited reverse share ([3a534c7](https://github.com/stonith404/pingvin-share/commit/3a534c7512ef82f3fa982f80e364f53c957306a0))
|
||||
* wrong TOTP validation for password ([2b7d3c0](https://github.com/stonith404/pingvin-share/commit/2b7d3c0a8a3e527fc1f7f86795731d5ac77eda49))
|
||||
|
||||
## [1.8.1](https://github.com/stonith404/pingvin-share/compare/v1.8.0...v1.8.1) (2025-01-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrong validation for expiration in reverse share modal ([b3ea96c](https://github.com/stonith404/pingvin-share/commit/b3ea96c1916980863fc6903c64cd2a7b32d66cfb))
|
||||
|
||||
## [1.8.0](https://github.com/stonith404/pingvin-share/compare/v1.7.2...v1.8.0) (2025-01-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add legal page with configuration options ([#724](https://github.com/stonith404/pingvin-share/issues/724)) ([df1ffaa](https://github.com/stonith404/pingvin-share/commit/df1ffaa2bcc047668cdc207cf8f86d821778cf44))
|
||||
* improve UI for timespan inputs on admin page ([#726](https://github.com/stonith404/pingvin-share/issues/726)) ([36afbf9](https://github.com/stonith404/pingvin-share/commit/36afbf91b7ba13e5ce42f2d91ec9898363a560b1))
|
||||
* **MyShares:** show information about own share security options ([#720](https://github.com/stonith404/pingvin-share/issues/720)) ([b58dcdb](https://github.com/stonith404/pingvin-share/commit/b58dcdba0b8688b286be4cc71796e2862553972a))
|
||||
* **UI:** improve filesize input and use it in settings ([#721](https://github.com/stonith404/pingvin-share/issues/721)) ([53c0551](https://github.com/stonith404/pingvin-share/commit/53c05518dfef4f65d76f5a1b301d0c5f8735576a))
|
||||
|
||||
## [1.7.2](https://github.com/stonith404/pingvin-share/compare/v1.7.1...v1.7.2) (2024-12-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* crash on zip download if zip is larger than 4GB ([#709](https://github.com/stonith404/pingvin-share/issues/709)) ([bfd4049](https://github.com/stonith404/pingvin-share/commit/bfd4049c154caae037db0458863e5c8c5d398848))
|
||||
|
||||
## [1.7.1](https://github.com/stonith404/pingvin-share/compare/v1.7.0...v1.7.1) (2024-12-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect ownership of the public folder ([6a97cc2](https://github.com/stonith404/pingvin-share/commit/6a97cc279c51bf125b9b516d1795f85b208e6ad5))
|
||||
|
||||
## [1.7.0](https://github.com/stonith404/pingvin-share/compare/v1.6.1...v1.7.0) (2024-12-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add support for S3 as a storage provider ([#659](https://github.com/stonith404/pingvin-share/issues/659)) ([5a54fe4](https://github.com/stonith404/pingvin-share/commit/5a54fe4cb7d9c22740edd8619c0a51044ca8c791))
|
||||
|
||||
## [1.6.1](https://github.com/stonith404/pingvin-share/compare/v1.6.0...v1.6.1) (2024-11-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error for non oidc oauth clients ([ba2e7e1](https://github.com/stonith404/pingvin-share/commit/ba2e7e122c45bfb2a783b15438112a79fee0c307))
|
||||
|
||||
## [1.6.0](https://github.com/stonith404/pingvin-share/compare/v1.5.0...v1.6.0) (2024-11-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add config variable to specify the requested OIDC sopes ([da54ce6](https://github.com/stonith404/pingvin-share/commit/da54ce6ee020a9718f55ec30c614607d411f55c8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add validation for share id and zip compression config variables ([3160f90](https://github.com/stonith404/pingvin-share/commit/3160f90e1d4bb3d6aa4017e98e400929fc4d3b2e))
|
||||
|
||||
## [1.5.0](https://github.com/stonith404/pingvin-share/compare/v1.4.0...v1.5.0) (2024-11-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **share:** add share ID length setting ([#677](https://github.com/stonith404/pingvin-share/issues/677)) ([9d4bb55](https://github.com/stonith404/pingvin-share/commit/9d4bb55a0945450f8a42c212d7f23983db38f37f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* totp can't be enabled if user is a ldap user ([c8f05f2](https://github.com/stonith404/pingvin-share/commit/c8f05f2475a5a54550cf64ef57c8b612580273be))
|
||||
|
||||
## [1.4.0](https://github.com/stonith404/pingvin-share/compare/v1.3.0...v1.4.0) (2024-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add "creatorEmail" config bariable to share recipient email message ([c7dacb2](https://github.com/stonith404/pingvin-share/commit/c7dacb26e87504a1c5e6b0d87cdcd5ed91b9cdf5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remote arbitrary file overwrite on file upload endpoint ([6cf5c66](https://github.com/stonith404/pingvin-share/commit/6cf5c66fe2eda1e0a525edf7440d047fe2f0e35b))
|
||||
|
||||
## [1.3.0](https://github.com/stonith404/pingvin-share/compare/v1.2.4...v1.3.0) (2024-11-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'secureCookies' configuration variable to explicitly set the secure flag and prevent confusion ([4ce6420](https://github.com/stonith404/pingvin-share/commit/4ce64206be7440a99299e1ed238ced7408c0563d))
|
||||
* add confirm dialog for leaving the page if an upload is in progress ([d8084e4](https://github.com/stonith404/pingvin-share/commit/d8084e401d7572b2d6e38ffa20cb678a0fb0e615))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve share completed dialog redirection for reverse shares ([4ef7ebb](https://github.com/stonith404/pingvin-share/commit/4ef7ebb0622f16d2d2c4d114b5fc15298e2ba24f))
|
||||
* prevent deletion of last admin account ([e1a5d19](https://github.com/stonith404/pingvin-share/commit/e1a5d195448e3d741b77fb982ce515489a360562))
|
||||
* throw error if no disk space is left ([c26de4e](https://github.com/stonith404/pingvin-share/commit/c26de4e881edfe6c7db617c0aeba89871397ebe2))
|
||||
* use current window url instead of app url in frontend ([6f45c3b](https://github.com/stonith404/pingvin-share/commit/6f45c3b1fbf4a95b29e5742878b55a1afa0b8886))
|
||||
|
||||
## [1.2.4](https://github.com/stonith404/pingvin-share/compare/v1.2.3...v1.2.4) (2024-10-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't enforce password lenght for sign in form because of LDAP ([428c1d2](https://github.com/stonith404/pingvin-share/commit/428c1d2b993a05a25cc94aabe56216b9ab969fa1))
|
||||
* use app name as totp issuer ([c89ca7e](https://github.com/stonith404/pingvin-share/commit/c89ca7e64b08f437dd1b7e9bf2b9d674cc612228))
|
||||
|
||||
## [1.2.3](https://github.com/stonith404/pingvin-share/compare/v1.2.2...v1.2.3) (2024-10-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share password can be bypassed if a deleted share with the same id was visited before ([acbff6e](https://github.com/stonith404/pingvin-share/commit/acbff6e129d236452180f8b96775457d135ac080))
|
||||
|
||||
## [1.2.2](https://github.com/stonith404/pingvin-share/compare/v1.2.1...v1.2.2) (2024-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** change general config icon to gear ([#649](https://github.com/stonith404/pingvin-share/issues/649)) ([958b79d](https://github.com/stonith404/pingvin-share/commit/958b79d787585c367a693872fd105a326e6e8d38))
|
||||
* environment variable `API_URL` can't be changed ([fe085b5](https://github.com/stonith404/pingvin-share/commit/fe085b58a5f3c0152df12957aa150c0876c2a074))
|
||||
|
||||
## [1.2.1](https://github.com/stonith404/pingvin-share/compare/v1.2.0...v1.2.1) (2024-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disallow passwort reset if it's a ldap user ([2e69224](https://github.com/stonith404/pingvin-share/commit/2e692241c57b001c9312302523c6374c0c24ea0c))
|
||||
* error message for invalid max use count of reverse share ([613bae9](https://github.com/stonith404/pingvin-share/commit/613bae90330a76c0964352a3fe927df3697309eb))
|
||||
* **oauth:** add `post_logout_redirect_uri` to OAuth logout redirect URI ([#638](https://github.com/stonith404/pingvin-share/issues/638)) ([bfbe8de](https://github.com/stonith404/pingvin-share/commit/bfbe8de98a6a7a2d32dd8d4dddbcc1d4ce6388f4))
|
||||
* share can't be created if an invalid email is entered in mail recipients ([d5cd300](https://github.com/stonith404/pingvin-share/commit/d5cd3002a1661e58d584e12280be36f17948c38c))
|
||||
* trim username, email and password on sign in and sign up page ([77a092a](https://github.com/stonith404/pingvin-share/commit/77a092a3cf089a4aa8b9897b5ad14e5500181d10))
|
||||
|
||||
## [1.2.0](https://github.com/stonith404/pingvin-share/compare/v1.1.3...v1.2.0) (2024-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** add ability to limit user IDs for Discord authentication ([#621](https://github.com/stonith404/pingvin-share/issues/621)) ([5883dff](https://github.com/stonith404/pingvin-share/commit/5883dff4cf0abe99b3ac8f0b56fdc9d04e80b51c))
|
||||
* **oauth:** Add option to logout from OpenID Connect provider ([2b3ce3f](https://github.com/stonith404/pingvin-share/commit/2b3ce3ffd250f7e3052d43c1c1e76947abf91e55)), closes [#598](https://github.com/stonith404/pingvin-share/issues/598)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use unique port env variable for backend ([d6b8b56](https://github.com/stonith404/pingvin-share/commit/d6b8b56247814087c2b676fe2367300172b5a94b))
|
||||
|
||||
## [1.1.3](https://github.com/stonith404/pingvin-share/compare/v1.1.2...v1.1.3) (2024-09-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve the LDAP implementation ([#615](https://github.com/stonith404/pingvin-share/issues/615)) ([3310fe5](https://github.com/stonith404/pingvin-share/commit/3310fe53b3e4c89db78d57ede6c8d57d8137ecc1)), closes [#601](https://github.com/stonith404/pingvin-share/issues/601)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* omit invalid username characters in oidc registration ([adc4af9](https://github.com/stonith404/pingvin-share/commit/adc4af996d30b295b06e4ee517aa53be62c0f6c1))
|
||||
|
||||
## [1.1.2](https://github.com/stonith404/pingvin-share/compare/v1.1.1...v1.1.2) (2024-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable auto complete for email recipients and share password ([ee73293](https://github.com/stonith404/pingvin-share/commit/ee73293c0f822d3e79cfefd096c656d4c36a12d1))
|
||||
* enable secure cookies if app url starts with https ([69752b8](https://github.com/stonith404/pingvin-share/commit/69752b8b417edda1ab4a4acedbdda09d545d6df8))
|
||||
|
||||
## [1.1.1](https://github.com/stonith404/pingvin-share/compare/v1.1.0...v1.1.1) (2024-09-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add environment variable to trust the reverse proxy ([b13a81a](https://github.com/stonith404/pingvin-share/commit/b13a81a88ca871c5714b2ed52d0e12fb7ceca176))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable email login if ldap is enabled ([d9cfe69](https://github.com/stonith404/pingvin-share/commit/d9cfe697d66e9db7bfbc2252b3700580793ce9bb))
|
||||
|
||||
## [1.1.0](https://github.com/stonith404/pingvin-share/compare/v1.0.4...v1.1.0) (2024-09-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow smpt without username and password ([8b3e28b](https://github.com/stonith404/pingvin-share/commit/8b3e28bac83e5326234096445395046ebdb0c4d7))
|
||||
* auto redirect to oauth provider ([7dc2e56](https://github.com/stonith404/pingvin-share/commit/7dc2e56fee1afc1078774cc702c0f1fee9bae938))
|
||||
|
||||
## [1.0.4](https://github.com/stonith404/pingvin-share/compare/v1.0.3...v1.0.4) (2024-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* oauth2 login can fail in some cases because the user can't be found ([92e1e82](https://github.com/stonith404/pingvin-share/commit/92e1e82e095075edf04019887f9c2048c21d00d6))
|
||||
|
||||
## [1.0.3](https://github.com/stonith404/pingvin-share/compare/v1.0.2...v1.0.3) (2024-09-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve oidc error logging ([dee7098](https://github.com/stonith404/pingvin-share/commit/dee70987eb74eda4a9ab7332522fa5540cee9761))
|
||||
|
||||
## [1.0.2](https://github.com/stonith404/pingvin-share/compare/v1.0.1...v1.0.2) (2024-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* default logo not displayed on fresh installations ([3e0735c](https://github.com/stonith404/pingvin-share/commit/3e0735c62079ac777fd08051b7e7602eebf74a5d))
|
||||
|
||||
## [1.0.1](https://github.com/stonith404/pingvin-share/compare/v1.0.0...v1.0.1) (2024-08-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **email:** add {email} placeholder to user invitation email ([#564](https://github.com/stonith404/pingvin-share/issues/564)) ([8c5c696](https://github.com/stonith404/pingvin-share/commit/8c5c696c514a5fb450462184240b21553d7f1532))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **translations:** add missing string for ldap group ([64efac5](https://github.com/stonith404/pingvin-share/commit/64efac5b685bf2de9d65c6a4f8890d45afe6476d))
|
||||
|
||||
## [1.0.0](https://github.com/stonith404/pingvin-share/compare/v0.29.0...v1.0.0) (2024-08-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **ldap:** Adding support for LDAP authentication ([#554](https://github.com/stonith404/pingvin-share/issues/554)) ([4186a76](https://github.com/stonith404/pingvin-share/commit/4186a768b310855282bc4876d1f294700963b8f5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* get started button on home page not working when sign-up is disabled ([4924f76](https://github.com/stonith404/pingvin-share/commit/4924f763947c9a6b79ba0d85887f104ed9545c78))
|
||||
* internal server error if user has no password when trying to sign in ([9c381a2](https://github.com/stonith404/pingvin-share/commit/9c381a2ed6b3b7dfd95d4278889b937ffb85e01b))
|
||||
|
||||
## [0.29.0](https://github.com/stonith404/pingvin-share/compare/v0.28.0...v0.29.0) (2024-07-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add more options to reverse shares ([#495](https://github.com/stonith404/pingvin-share/issues/495)) ([fe735f9](https://github.com/stonith404/pingvin-share/commit/fe735f9704c9d96398f3127a559e17848b08d140)), closes [#155](https://github.com/stonith404/pingvin-share/issues/155)
|
||||
* sort share files by name by default ([27ee9fb](https://github.com/stonith404/pingvin-share/commit/27ee9fb6cb98177661bed20a0baa399b27e70b7e))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "fix: set max age of access token cookie to 15 minutes" ([14c2185](https://github.com/stonith404/pingvin-share/commit/14c2185e6f1a81d63e25fbeec3e30a54cf6a44c5))
|
||||
|
||||
## [0.28.0](https://github.com/stonith404/pingvin-share/compare/v0.27.0...v0.28.0) (2024-07-22)
|
||||
|
||||
|
||||
|
||||
15
Caddyfile
15
Caddyfile
@@ -1,15 +0,0 @@
|
||||
:3000 {
|
||||
# Reverse proxy for /api
|
||||
reverse_proxy /api/* http://localhost:8080 {
|
||||
header_up X-Forwarded-Host {host}:{server_port}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
|
||||
# Reverse proxy for all other requests
|
||||
reverse_proxy http://localhost:3333 {
|
||||
header_up X-Forwarded-Host {host}:{server_port}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,25 +1,27 @@
|
||||
# Stage 1: Frontend dependencies
|
||||
FROM node:20-alpine AS frontend-dependencies
|
||||
FROM node:22-alpine AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
FROM node:22-alpine AS backend-dependencies
|
||||
RUN apk add --no-cache python3
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4: Build backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
FROM node:22-alpine AS backend-builder
|
||||
RUN apk add openssl
|
||||
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||
@@ -27,15 +29,15 @@ RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
ENV NODE_ENV=docker
|
||||
|
||||
# Install Caddy
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk update --no-cache \
|
||||
&& apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl caddy
|
||||
|
||||
COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||
&& apk add --no-cache curl caddy su-exec openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /opt/app/public ./public
|
||||
@@ -48,13 +50,16 @@ COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||
COPY --from=backend-builder /opt/app/dist ./dist
|
||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
COPY --from=backend-builder /opt/app/tsconfig.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
COPY ./reverse-proxy /opt/app/reverse-proxy
|
||||
COPY ./scripts/docker ./scripts/docker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check remains unchanged
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD /bin/sh -c '(if [[ "$CADDY_DISABLED" = "true" ]]; then curl -fs http://localhost:${BACKEND_PORT:-8080}/api/health; else curl -fs http://localhost:3000/api/health; fi) || exit 1'
|
||||
|
||||
# Application startup updated for Caddy
|
||||
CMD cp -rn /tmp/img/* /opt/app/frontend/public/img && caddy run --config /etc/caddy/Caddyfile & PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
149
README.md
149
README.md
@@ -1,12 +1,18 @@
|
||||
> ## ⚠️ Project Archived
|
||||
>
|
||||
> After much consideration, I've chosen to focus my limited time and energy on my other project, [Pocket ID](https://github.com/pocket-id/pocket-id). As a solo developer, I've found it difficult to actively maintain multiple open source projects with the care and attention they deserve.
|
||||
>
|
||||
> If you're interested in continuing this work through a fork, I'd be happy to link to it here in the README.
|
||||
>
|
||||
> Thanks to all the contributors and users who have supported Pingvin Share over the years :)
|
||||
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
||||
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [简体中文](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
Pingvin Share is a self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -15,7 +21,10 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Reverse shares
|
||||
- OIDC and LDAP authentication
|
||||
- Integration with ClamAV for security scans
|
||||
- Different file providers: local storage and S3
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
|
||||
@@ -26,8 +35,6 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
|
||||
## ⌨️ Setup
|
||||
|
||||
> Note: Pingvin Share is in its early stages and may contain bugs.
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
1. Download the `docker-compose.yml` file
|
||||
@@ -35,135 +42,13 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Stand-alone Installation
|
||||
> [!TIP]
|
||||
> Checkout [Pocket ID](https://github.com/stonith404/pocket-id), a user-friendly OIDC provider that lets you easily log in to services like Pingvin Share using Passkeys.
|
||||
|
||||
Required tools:
|
||||
## 📚 Documentation
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||
pm2 start --name="pingvin-share-frontend" .next/standalone/server.js
|
||||
```
|
||||
|
||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./Caddyfile`.
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Integrations
|
||||
|
||||
#### ClamAV (Docker only)
|
||||
|
||||
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||
|
||||
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
#### OAuth 2 Login
|
||||
|
||||
View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information.
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
- [Zeabur installation](https://zeabur.com/templates/19G6OK)
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
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 install
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can customize Pingvin Share like changing your domain by going to the configuration page in your admin dashboard `/admin/config`.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
##### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
|
||||
##### Frontend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
### 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.
|
||||
We would love it if you want to help make Pingvin Share better! You can either [help to translate](https://stonith404.github.io/pingvin-share/help-out/translate) Pingvin Share or [contribute to the codebase](https://stonith404.github.io/pingvin-share/help-out/contribute).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
As Pingvin Share is in beta, older versions don't get security updates. Please consider to update Pingvin Share regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
||||
|
||||
Older versions of Pingvin Share do not receive security updates. To ensure your system remains secure, we strongly recommend updating Pingvin Share regularly. You can automate these updates using tools like [Watchtower](https://github.com/containrrr/watchtower).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||
|
||||
7897
backend/package-lock.json
generated
7897
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.28.0",
|
||||
"version": "1.13.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
@@ -13,73 +13,80 @@
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.3.9",
|
||||
"@nestjs/config": "^3.2.2",
|
||||
"@nestjs/core": "^10.3.9",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.9",
|
||||
"@nestjs/schedule": "^4.0.2",
|
||||
"@nestjs/swagger": "^7.3.1",
|
||||
"@nestjs/throttler": "^5.2.0",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@aws-sdk/client-s3": "^3.787.0",
|
||||
"@keyv/redis": "^4.4.0",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.17",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.17",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.17",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/swagger": "^11.1.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.40.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cache-manager": "^5.6.1",
|
||||
"clamscan": "^2.2.1",
|
||||
"argon2": "^0.41.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"cache-manager": "^6.4.2",
|
||||
"cacheable": "^1.9.0",
|
||||
"clamscan": "^2.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"jmespath": "^0.16.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"ldapts": "^7.4.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "^6.10.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^5.0.7",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.4",
|
||||
"ts-node": "^10.9.2"
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.34.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"yaml": "^2.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@nestjs/testing": "^10.3.9",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/clamscan": "^2.0.8",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@nestjs/cli": "^11.0.6",
|
||||
"@nestjs/schematics": "^11.0.5",
|
||||
"@nestjs/testing": "^11.0.17",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/clamscan": "^2.4.1",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cron": "^2.4.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode-svg": "^1.1.4",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"@types/qrcode-svg": "^1.1.5",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.1",
|
||||
"@typescript-eslint/parser": "^8.29.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"newman": "^6.1.3",
|
||||
"prettier": "^3.3.2",
|
||||
"prisma": "^5.16.1",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"newman": "^6.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prisma": "^6.6.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.5.2",
|
||||
"wait-on": "^7.2.0"
|
||||
"typescript": "^5.8.3",
|
||||
"wait-on": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
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,
|
||||
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"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", "remainingUses", "sendEmailNotification", "shareExpiration", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token" 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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
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,
|
||||
"simplified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publicAccess" BOOLEAN NOT NULL DEFAULT true,
|
||||
"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", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token" FROM "ReverseShare";
|
||||
DROP TABLE "ReverseShare";
|
||||
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[ldapDN]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "ldapDN" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_ldapDN_key" ON "User"("ldapDN");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT,
|
||||
"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,
|
||||
"storageProvider" TEXT NOT NULL DEFAULT 'LOCAL',
|
||||
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", "name", "removedReason", "reverseShareId", "uploadLocked", "views") SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "name", "removedReason", "reverseShareId", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE Config SET `value` = `value` || ' hours' WHERE name = "maxExpiration" OR name = "sessionDuration";
|
||||
@@ -16,6 +16,7 @@ model User {
|
||||
email String @unique
|
||||
password String?
|
||||
isAdmin Boolean @default(false)
|
||||
ldapDN String? @unique
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
@@ -39,6 +40,8 @@ model RefreshToken {
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
oauthIDToken String? // prefixed with the ID of the issuing OAuth provider, separated by a colon
|
||||
}
|
||||
|
||||
model LoginToken {
|
||||
@@ -92,6 +95,7 @@ model Share {
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
storageProvider String @default("LOCAL")
|
||||
}
|
||||
|
||||
model ReverseShare {
|
||||
@@ -103,6 +107,8 @@ model ReverseShare {
|
||||
maxShareSize String
|
||||
sendEmailNotification Boolean
|
||||
remainingUses Int
|
||||
simplified Boolean @default(false)
|
||||
publicAccess Boolean @default(true)
|
||||
|
||||
creatorId String
|
||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables: ConfigVariables = {
|
||||
export const configVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
type: "string",
|
||||
@@ -20,14 +20,18 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
secureCookies: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
showHomePage: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
sessionDuration: {
|
||||
type: "number",
|
||||
defaultValue: "2160",
|
||||
type: "timespan",
|
||||
defaultValue: "3 months",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
@@ -43,12 +47,17 @@ const configVariables: ConfigVariables = {
|
||||
secret: false,
|
||||
},
|
||||
maxExpiration: {
|
||||
type: "timespan",
|
||||
defaultValue: "0 days",
|
||||
secret: false,
|
||||
},
|
||||
shareIdLength: {
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
defaultValue: "8",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
type: "number",
|
||||
type: "filesize",
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
@@ -57,7 +66,7 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "9",
|
||||
},
|
||||
chunkSize: {
|
||||
type: "number",
|
||||
type: "filesize",
|
||||
defaultValue: "10000000",
|
||||
secret: false,
|
||||
},
|
||||
@@ -67,6 +76,25 @@ const configVariables: ConfigVariables = {
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
"redis-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"redis-url": {
|
||||
type: "string",
|
||||
defaultValue: "redis://pingvin-redis:6379",
|
||||
secret: true,
|
||||
},
|
||||
ttl: {
|
||||
type: "number",
|
||||
defaultValue: "60",
|
||||
},
|
||||
maxItems: {
|
||||
type: "number",
|
||||
defaultValue: "1000",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
type: "boolean",
|
||||
@@ -80,7 +108,7 @@ const configVariables: ConfigVariables = {
|
||||
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 🐧",
|
||||
"Hey!\n\n{creator} ({creatorEmail}) shared some files with you. You can 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",
|
||||
@@ -98,7 +126,7 @@ const configVariables: ConfigVariables = {
|
||||
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 🐧",
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in an hour.\n\nPingvin Share 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
type: "string",
|
||||
@@ -107,7 +135,7 @@ const configVariables: ConfigVariables = {
|
||||
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 🐧",
|
||||
'Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYou can use the email "{email}" and the password "{password}" to sign in.\n\nPingvin Share 🐧',
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
@@ -144,16 +172,61 @@ const configVariables: ConfigVariables = {
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
ldap: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
|
||||
url: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
bindDn: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
bindPassword: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
|
||||
searchBase: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
searchQuery: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
adminGroups: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
|
||||
fieldNameMemberOf: {
|
||||
type: "string",
|
||||
defaultValue: "memberOf",
|
||||
},
|
||||
fieldNameEmail: {
|
||||
type: "string",
|
||||
defaultValue: "userPrincipalName",
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
"allowRegistration": {
|
||||
allowRegistration: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"ignoreTotp": {
|
||||
ignoreTotp: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"disablePassword": {
|
||||
disablePassword: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
@@ -209,6 +282,10 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-limitedUsers": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
@@ -226,6 +303,14 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-signOut": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-scope": {
|
||||
type: "string",
|
||||
defaultValue: "openid email profile",
|
||||
},
|
||||
"oidc-usernameClaim": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
@@ -252,6 +337,84 @@ const configVariables: ConfigVariables = {
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
s3: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
endpoint: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
bucketName: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
bucketPath: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: true,
|
||||
},
|
||||
secret: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
useChecksum: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
},
|
||||
legal: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
imprintText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
imprintUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
} satisfies ConfigVariables;
|
||||
|
||||
export type YamlConfig = {
|
||||
[Category in keyof typeof configVariables]: {
|
||||
[Key in keyof (typeof configVariables)[Category]]: string;
|
||||
};
|
||||
} & {
|
||||
initUser: {
|
||||
enabled: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
isAdmin: boolean;
|
||||
ldapDN: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfigVariables = {
|
||||
@@ -275,11 +438,11 @@ const prisma = new PrismaClient({
|
||||
|
||||
async function seedConfigVariables() {
|
||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||
configVariables
|
||||
configVariables,
|
||||
)) {
|
||||
let order = 0;
|
||||
for (const [name, properties] of Object.entries(
|
||||
configVariablesOfCategory
|
||||
configVariablesOfCategory,
|
||||
)) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { name_category: { name, category } },
|
||||
@@ -325,7 +488,7 @@ async function migrateConfigVariables() {
|
||||
// Update the config variable if it exists in the seed
|
||||
} else {
|
||||
const variableOrder = Object.keys(
|
||||
configVariables[existingConfigVariable.category]
|
||||
configVariables[existingConfigVariable.category],
|
||||
).indexOf(existingConfigVariable.name);
|
||||
await prisma.config.update({
|
||||
where: {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Module } from "@nestjs/common";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AppCacheModule } from "./cache/cache.module";
|
||||
import { AppController } from "./app.controller";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
@@ -20,12 +20,12 @@ import { UserModule } from "./user/user.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
ThrottlerModule.forRoot([
|
||||
@@ -38,9 +38,7 @@ import { UserModule } from "./user/user.module";
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
AppCacheModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -120,7 +120,7 @@ export class AuthController {
|
||||
})
|
||||
@HttpCode(202)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
this.authService.requestResetPassword(email);
|
||||
await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@@ -172,13 +172,25 @@ export class AuthController {
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
await this.authService.signOut(request.cookies.access_token);
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
const redirectURI = await this.authService.signOut(
|
||||
request.cookies.access_token,
|
||||
);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
response.cookie("access_token", "", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
|
||||
if (typeof redirectURI === "string") {
|
||||
return { redirectURI: redirectURI.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
@Post("totp/enable")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
import { LdapService } from "./ldap.service";
|
||||
import { UserModule } from "../user/user.module";
|
||||
import { OAuthModule } from "../oauth/oauth.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -12,9 +15,11 @@ import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
forwardRef(() => OAuthModule),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
@@ -14,8 +16,12 @@ import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { OAuthService } from "../oauth/oauth.service";
|
||||
import { GenericOidcProvider } from "../oauth/provider/genericOidc.provider";
|
||||
import { UserSevice } from "../user/user.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { LdapService } from "./ldap.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -24,6 +30,9 @@ export class AuthService {
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||
) {}
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
@@ -61,36 +70,57 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username)
|
||||
if (!dto.email && !dto.username) {
|
||||
throw new BadRequestException("Email or username is required");
|
||||
|
||||
if (this.config.get("oauth.disablePassword"))
|
||||
throw new ForbiddenException("Password sign in is disabled");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password))) {
|
||||
this.logger.log(
|
||||
`Failed login attempt for user ${dto.email} from IP ${ip}`,
|
||||
);
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
}
|
||||
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.generateToken(user);
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.password && (await argon.verify(user.password, dto.password))) {
|
||||
this.logger.log(
|
||||
`Successful password login for user ${user.email} from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.get("ldap.enabled")) {
|
||||
/*
|
||||
* E-mail-like user credentials are passed as the email property
|
||||
* instead of the username. Since the username format does not matter
|
||||
* when searching for users in LDAP, we simply use the username
|
||||
* in whatever format it is provided.
|
||||
*/
|
||||
const ldapUsername = dto.username || dto.email;
|
||||
this.logger.debug(`Trying LDAP login for user ${ldapUsername}`);
|
||||
const ldapUser = await this.ldapService.authenticateUser(
|
||||
ldapUsername,
|
||||
dto.password,
|
||||
);
|
||||
if (ldapUser) {
|
||||
const user = await this.userService.findOrCreateFromLDAP(dto, ldapUser);
|
||||
this.logger.log(
|
||||
`Successful LDAP login for user ${ldapUsername} (${user.id}) from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Failed login attempt for user ${dto.email || dto.username} from IP ${ip}`,
|
||||
);
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
}
|
||||
|
||||
async generateToken(user: User, isOAuth = false) {
|
||||
async generateToken(user: User, oauth?: { idToken?: string }) {
|
||||
// TODO: Make all old loginTokens invalid when a new one is created
|
||||
// Check if the user has TOTP enabled
|
||||
if (
|
||||
user.totpVerified &&
|
||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
||||
) {
|
||||
if (user.totpVerified && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
@@ -98,6 +128,7 @@ export class AuthService {
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
oauth?.idToken,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -115,6 +146,15 @@ export class AuthService {
|
||||
|
||||
if (!user) return;
|
||||
|
||||
if (user.ldapDN) {
|
||||
this.logger.log(
|
||||
`Failed password reset request for user ${email} because it is an LDAP user`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"This account can't reset its password here. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
@@ -129,7 +169,7 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
@@ -196,12 +236,54 @@ export class AuthService {
|
||||
}) || {};
|
||||
|
||||
if (refreshTokenId) {
|
||||
const oauthIDToken = await this.prisma.refreshToken
|
||||
.findFirst({
|
||||
select: { oauthIDToken: true },
|
||||
where: { id: refreshTokenId },
|
||||
})
|
||||
.then((refreshToken) => refreshToken?.oauthIDToken)
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
await this.prisma.refreshToken
|
||||
.delete({ where: { id: refreshTokenId } })
|
||||
.catch((e) => {
|
||||
// Ignore error if refresh token doesn't exist
|
||||
if (e.code != "P2025") throw e;
|
||||
});
|
||||
|
||||
if (typeof oauthIDToken === "string") {
|
||||
const [providerName, idTokenHint] = oauthIDToken.split(":");
|
||||
const provider = this.oAuthService.availableProviders()[providerName];
|
||||
let signOutFromProviderSupportedAndActivated = false;
|
||||
try {
|
||||
signOutFromProviderSupportedAndActivated = this.config.get(
|
||||
`oauth.${providerName}-signOut`,
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignore error if the provider is not supported or if the provider sign out is not activated
|
||||
}
|
||||
if (
|
||||
provider instanceof GenericOidcProvider &&
|
||||
signOutFromProviderSupportedAndActivated
|
||||
) {
|
||||
const configuration = await provider.getConfiguration();
|
||||
if (URL.canParse(configuration.end_session_endpoint)) {
|
||||
const redirectURI = new URL(configuration.end_session_endpoint);
|
||||
redirectURI.searchParams.append(
|
||||
"post_logout_redirect_uri",
|
||||
this.config.get("general.appUrl"),
|
||||
);
|
||||
redirectURI.searchParams.append("id_token_hint", idTokenHint);
|
||||
redirectURI.searchParams.append(
|
||||
"client_id",
|
||||
this.config.get(`oauth.${providerName}-clientId`),
|
||||
);
|
||||
return redirectURI.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,13 +302,15 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async createRefreshToken(userId: string) {
|
||||
async createRefreshToken(userId: string, idToken?: string) {
|
||||
const sessionDuration = this.config.get("general.sessionDuration");
|
||||
const { id, token } = await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: moment()
|
||||
.add(this.config.get("general.sessionDuration"), "hours")
|
||||
.add(sessionDuration.value, sessionDuration.unit)
|
||||
.toDate(),
|
||||
oauthIDToken: idToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -248,18 +332,27 @@ export class AuthService {
|
||||
refreshToken?: string,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, {
|
||||
sameSite: "lax",
|
||||
maxAge: 1000 * 60 * 15, // 15 minutes
|
||||
secure: isSecure,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
||||
});
|
||||
if (refreshToken)
|
||||
if (refreshToken) {
|
||||
const now = moment();
|
||||
const sessionDuration = this.config.get("general.sessionDuration");
|
||||
const maxAge = moment(now)
|
||||
.add(sessionDuration.value, sessionDuration.unit)
|
||||
.diff(now);
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"),
|
||||
secure: isSecure,
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,4 +372,12 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPassword(user: User, password: string) {
|
||||
if (!user.password && this.config.get("ldap.enabled")) {
|
||||
return !!this.ldapService.authenticateUser(user.username, password);
|
||||
}
|
||||
|
||||
return argon.verify(user.password, password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -16,6 +16,7 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
@@ -63,7 +64,7 @@ export class AuthTotpService {
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
// Check if we have a secret already
|
||||
@@ -76,13 +77,10 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
const issuer = this.configService.get("general.appName");
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
"pingvin-share",
|
||||
secret,
|
||||
);
|
||||
const otpURL = totp.keyuri(user.username || user.email, issuer, secret);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -107,9 +105,8 @@ export class AuthTotpService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
|
||||
async verifyTotp(user: User, password: string, code: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
@@ -138,7 +135,7 @@ export class AuthTotpService {
|
||||
}
|
||||
|
||||
async disableTotp(user: User, password: string, code: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
export class AuthSignInDTO {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
@@ -10,4 +8,7 @@ export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||
export class EnableTotpDTO {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
105
backend/src/auth/ldap.service.ts
Normal file
105
backend/src/auth/ldap.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { inspect } from "node:util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { Client, Entry, InvalidCredentialsError } from "ldapts";
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
private readonly logger = new Logger(LdapService.name);
|
||||
constructor(
|
||||
@Inject(ConfigService)
|
||||
private readonly serviceConfig: ConfigService,
|
||||
) {}
|
||||
|
||||
private async createLdapConnection(): Promise<Client> {
|
||||
const ldapUrl = this.serviceConfig.get("ldap.url");
|
||||
if (!ldapUrl) {
|
||||
throw new Error("LDAP server URL is not defined");
|
||||
}
|
||||
|
||||
const ldapClient = new Client({
|
||||
url: ldapUrl,
|
||||
timeout: 15_000,
|
||||
connectTimeout: 15_000,
|
||||
});
|
||||
|
||||
const bindDn = this.serviceConfig.get("ldap.bindDn") || null;
|
||||
if (bindDn) {
|
||||
try {
|
||||
await ldapClient.bind(
|
||||
bindDn,
|
||||
this.serviceConfig.get("ldap.bindPassword"),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to bind to default user: ${error}`);
|
||||
throw new Error("failed to bind to default user");
|
||||
}
|
||||
}
|
||||
|
||||
return ldapClient;
|
||||
}
|
||||
|
||||
public async authenticateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Entry | null> {
|
||||
if (!username.match(/^[a-zA-Z0-9-_.@]+$/)) {
|
||||
this.logger.verbose(
|
||||
`Username ${username} does not match username pattern. Authentication failed.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchBase = this.serviceConfig.get("ldap.searchBase");
|
||||
const searchQuery = this.serviceConfig
|
||||
.get("ldap.searchQuery")
|
||||
.replaceAll("%username%", username);
|
||||
|
||||
const ldapClient = await this.createLdapConnection();
|
||||
try {
|
||||
const { searchEntries } = await ldapClient.search(searchBase, {
|
||||
filter: searchQuery,
|
||||
scope: "sub",
|
||||
|
||||
attributes: ["*"],
|
||||
returnAttributeValues: true,
|
||||
});
|
||||
|
||||
if (searchEntries.length > 1) {
|
||||
/* too many users found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. Too many users found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
} else if (searchEntries.length == 0) {
|
||||
/* user not found */
|
||||
this.logger.verbose(
|
||||
`Authentication for username ${username} failed. No user found with query ${searchQuery}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetEntity = searchEntries[0];
|
||||
this.logger.verbose(
|
||||
`Trying to authenticate ${username} against LDAP user ${targetEntity.dn}`,
|
||||
);
|
||||
try {
|
||||
await ldapClient.bind(targetEntity.dn, password);
|
||||
return targetEntity;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
this.logger.verbose(
|
||||
`Failed to authenticate ${username} against ${targetEntity.dn}. Invalid credentials.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.warn(`User bind failure: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Connect error: ${inspect(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/src/cache/cache.module.ts
vendored
Normal file
41
backend/src/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { CacheableMemory } from "cacheable";
|
||||
import { createKeyv } from "@keyv/redis";
|
||||
import { Keyv } from "keyv";
|
||||
import { ConfigModule } from "src/config/config.module";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const useRedis = configService.get("cache.redis-enabled");
|
||||
const ttl = configService.get("cache.ttl");
|
||||
const max = configService.get("cache.maxItems");
|
||||
|
||||
let config = {
|
||||
ttl,
|
||||
max,
|
||||
stores: [],
|
||||
};
|
||||
|
||||
if (useRedis) {
|
||||
const redisUrl = configService.get("cache.redis-url");
|
||||
config.stores = [
|
||||
new Keyv({ store: new CacheableMemory({ ttl, lruSize: 5000 }) }),
|
||||
createKeyv(redisUrl),
|
||||
];
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [CacheModule],
|
||||
})
|
||||
export class AppCacheModule {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
@@ -16,7 +17,15 @@ import { LogoService } from "./logo.service";
|
||||
},
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useFactory: async (prisma: PrismaService, configVariables: Config[]) => {
|
||||
const configService = new ConfigService(configVariables, prisma);
|
||||
await configService.initialize();
|
||||
return configService;
|
||||
},
|
||||
inject: [PrismaService, "CONFIG_VARIABLES"],
|
||||
},
|
||||
LogoService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
|
||||
@@ -2,11 +2,18 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as argon from "argon2";
|
||||
import { EventEmitter } from "events";
|
||||
import * as fs from "fs";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { stringToTimespan } from "src/utils/date.util";
|
||||
import { parse as yamlParse } from "yaml";
|
||||
import { YamlConfig } from "../../prisma/seed/config.seed";
|
||||
import { CONFIG_FILE } from "src/constants";
|
||||
|
||||
/**
|
||||
* ConfigService extends EventEmitter to allow listening for config updates,
|
||||
@@ -14,6 +21,9 @@ import { EventEmitter } from "events";
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigService extends EventEmitter {
|
||||
yamlConfig?: YamlConfig;
|
||||
logger = new Logger(ConfigService.name);
|
||||
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService,
|
||||
@@ -21,6 +31,67 @@ export class ConfigService extends EventEmitter {
|
||||
super();
|
||||
}
|
||||
|
||||
// Initialize gets called by the ConfigModule
|
||||
async initialize() {
|
||||
await this.loadYamlConfig();
|
||||
|
||||
if (this.yamlConfig) {
|
||||
await this.migrateInitUser();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadYamlConfig() {
|
||||
let configFile: string = "";
|
||||
try {
|
||||
configFile = fs.readFileSync(CONFIG_FILE, "utf8");
|
||||
} catch (e) {
|
||||
this.logger.log(
|
||||
"Config.yaml is not set. Falling back to UI configuration.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
this.yamlConfig = yamlParse(configFile);
|
||||
|
||||
if (this.yamlConfig) {
|
||||
for (const configVariable of this.configVariables) {
|
||||
const category = this.yamlConfig[configVariable.category];
|
||||
if (!category) continue;
|
||||
configVariable.value = category[configVariable.name];
|
||||
this.emit("update", configVariable.name, configVariable.value);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
"Failed to parse config.yaml. Falling back to UI configuration: ",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateInitUser(): Promise<void> {
|
||||
if (!this.yamlConfig.initUser.enabled) return;
|
||||
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
if (userCount === 1) {
|
||||
this.logger.log(
|
||||
"Skip initial user creation. Admin user is already existent.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.prisma.user.create({
|
||||
data: {
|
||||
email: this.yamlConfig.initUser.email,
|
||||
username: this.yamlConfig.initUser.username,
|
||||
password: this.yamlConfig.initUser.password
|
||||
? await argon.hash(this.yamlConfig.initUser.password)
|
||||
: null,
|
||||
isAdmin: this.yamlConfig.initUser.isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get(key: `${string}.${string}`): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => `${variable.category}.${variable.name}` == key,
|
||||
@@ -30,31 +101,31 @@ export class ConfigService extends EventEmitter {
|
||||
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "number" || configVariable.type == "filesize")
|
||||
return parseInt(value);
|
||||
if (configVariable.type == "boolean") return value == "true";
|
||||
if (configVariable.type == "string" || configVariable.type == "text")
|
||||
return value;
|
||||
if (configVariable.type == "timespan") return stringToTimespan(value);
|
||||
}
|
||||
|
||||
async getByCategory(category: string) {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
where: { category, locked: { equals: false } },
|
||||
});
|
||||
const configVariables = this.configVariables
|
||||
.filter((c) => !c.locked && category == c.category)
|
||||
.sort((c) => c.order);
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
allowEdit: this.isEditAllowed(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
where: { secret: { equals: false } },
|
||||
});
|
||||
const configVariables = this.configVariables.filter((c) => !c.secret);
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
@@ -66,6 +137,11 @@ export class ConfigService extends EventEmitter {
|
||||
}
|
||||
|
||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||
if (!this.isEditAllowed())
|
||||
throw new BadRequestException(
|
||||
"You are only allowed to update config variables via the config.yaml file",
|
||||
);
|
||||
|
||||
const response: Config[] = [];
|
||||
|
||||
for (const variable of data) {
|
||||
@@ -76,6 +152,11 @@ export class ConfigService extends EventEmitter {
|
||||
}
|
||||
|
||||
async update(key: string, value: string | number | boolean) {
|
||||
if (!this.isEditAllowed())
|
||||
throw new BadRequestException(
|
||||
"You are only allowed to update config variables via the config.yaml file",
|
||||
);
|
||||
|
||||
const configVariable = await this.prisma.config.findUnique({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -93,13 +174,16 @@ export class ConfigService extends EventEmitter {
|
||||
} else if (
|
||||
typeof value != configVariable.type &&
|
||||
typeof value == "string" &&
|
||||
configVariable.type != "text"
|
||||
configVariable.type != "text" &&
|
||||
configVariable.type != "timespan"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Config variable must be of type ${configVariable.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.validateConfigVariable(key, value);
|
||||
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -116,4 +200,29 @@ export class ConfigService extends EventEmitter {
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
|
||||
validateConfigVariable(key: string, value: string | number | boolean) {
|
||||
const validations = [
|
||||
{
|
||||
key: "share.shareIdLength",
|
||||
condition: (value: number) => value >= 2 && value <= 50,
|
||||
message: "Share ID length must be between 2 and 50",
|
||||
},
|
||||
{
|
||||
key: "share.zipCompressionLevel",
|
||||
condition: (value: number) => value >= 0 && value <= 9,
|
||||
message: "Zip compression level must be between 0 and 9",
|
||||
},
|
||||
// TODO add validation for timespan type
|
||||
];
|
||||
|
||||
const validation = validations.find((validation) => validation.key == key);
|
||||
if (validation && !validation.condition(value as any)) {
|
||||
throw new BadRequestException(validation.message);
|
||||
}
|
||||
}
|
||||
|
||||
isEditAllowed(): boolean {
|
||||
return this.yamlConfig === undefined || this.yamlConfig === null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
|
||||
@Expose()
|
||||
allowEdit: boolean;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { LogLevel } from "@nestjs/common";
|
||||
|
||||
export const CONFIG_FILE = process.env.CONFIG_FILE || "../config.yaml";
|
||||
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||
export const DATABASE_URL =
|
||||
@@ -7,3 +11,7 @@ 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;
|
||||
|
||||
export const LOG_LEVEL_AVAILABLE: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error', 'fatal'];
|
||||
export const LOG_LEVEL_DEFAULT: LogLevel = process.env.NODE_ENV === 'development' ? "verbose" : "log";
|
||||
export const LOG_LEVEL_ENV = `${process.env.PV_LOG_LEVEL || ""}`;
|
||||
@@ -17,14 +17,15 @@ export class EmailService {
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
throw new InternalServerErrorException("SMTP is disabled");
|
||||
|
||||
const username = this.config.get("smtp.username");
|
||||
const password = this.config.get("smtp.password");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: this.config.get("smtp.host"),
|
||||
port: this.config.get("smtp.port"),
|
||||
secure: this.config.get("smtp.port") == 465,
|
||||
auth: {
|
||||
user: this.config.get("smtp.username"),
|
||||
pass: this.config.get("smtp.password"),
|
||||
},
|
||||
auth:
|
||||
username || password ? { user: username, pass: password } : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: !this.config.get(
|
||||
"smtp.allowUnauthorizedCertificates",
|
||||
@@ -68,6 +69,7 @@ export class EmailService {
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{creatorEmail}", creator?.email ?? "")
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{desc}", description ?? "No description")
|
||||
.replaceAll(
|
||||
@@ -116,7 +118,8 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password),
|
||||
.replaceAll("{password}", password)
|
||||
.replaceAll("{email}", recipientEmail),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||
import * as mime from "mime-types";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
@@ -53,13 +54,14 @@ export class FileController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
const zipStream = await this.fileService.getZip(shareId);
|
||||
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
return new StreamableFile(zipStream);
|
||||
}
|
||||
|
||||
@Get(":fileId")
|
||||
@@ -73,13 +75,18 @@ export class FileController {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
|
||||
const headers = {
|
||||
"Content-Type": file.metaData.mimeType,
|
||||
"Content-Type":
|
||||
mime?.lookup?.(file.metaData.name) || "application/octet-stream",
|
||||
"Content-Length": file.metaData.size,
|
||||
"Content-Security-Policy": "script-src 'none'",
|
||||
"Content-Security-Policy": "sandbox",
|
||||
};
|
||||
|
||||
if (download === "true") {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||
} else {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name, {
|
||||
type: "inline",
|
||||
});
|
||||
}
|
||||
|
||||
res.set(headers);
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||
import { ShareModule } from "src/share/share.module";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
import { LocalFileService } from "./local.service";
|
||||
import { S3FileService } from "./s3.service";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ReverseShareModule, ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
providers: [FileService, LocalFileService, S3FileService],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
||||
@@ -1,149 +1,88 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { LocalFileService } from "./local.service";
|
||||
import { S3FileService } from "./s3.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { Readable } from "stream";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private localFileService: LocalFileService,
|
||||
private s3FileService: S3FileService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
// Determine which service to use based on the current config value
|
||||
// shareId is optional -> can be used to overwrite a storage provider
|
||||
private getStorageService(
|
||||
storageProvider?: string,
|
||||
): S3FileService | LocalFileService {
|
||||
if (storageProvider != undefined)
|
||||
return storageProvider == "S3"
|
||||
? this.s3FileService
|
||||
: this.localFileService;
|
||||
return this.configService.get("s3.enabled")
|
||||
? this.s3FileService
|
||||
: this.localFileService;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
file: {
|
||||
id?: string;
|
||||
name: string;
|
||||
},
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) file.id = crypto.randomUUID();
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { files: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
throw new BadRequestException("Share is already completed");
|
||||
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
}
|
||||
|
||||
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||
const chunkSize = this.config.get("share.chunkSize");
|
||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
throw new BadRequestException({
|
||||
message: "Unexpected chunk received",
|
||||
error: "unexpected_chunk_index",
|
||||
expectedChunkIndex,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0,
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
shareSizeSum > this.config.get("share.maxSize") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
fs.renameSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.create(data, chunk, file, shareId);
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
async get(shareId: string, fileId: string): Promise<File> {
|
||||
const share = await this.prisma.share.findFirst({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
mimeType: mime.contentType(fileMetaData.name.split(".").pop()),
|
||||
...fileMetaData,
|
||||
size: fileMetaData.size,
|
||||
},
|
||||
file,
|
||||
};
|
||||
const storageService = this.getStorageService(share.storageProvider);
|
||||
return storageService.get(shareId, fileId);
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.remove(shareId, fileId);
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const storageService = this.getStorageService();
|
||||
return storageService.deleteAllFiles(shareId);
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
async getZip(shareId: string): Promise<Readable> {
|
||||
const storageService = this.getStorageService();
|
||||
return await storageService.getZip(shareId);
|
||||
}
|
||||
|
||||
private async streamToUint8Array(stream: Readable): Promise<Uint8Array> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface File {
|
||||
metaData: {
|
||||
id: string;
|
||||
size: string;
|
||||
createdAt: Date;
|
||||
mimeType: string | false;
|
||||
name: string;
|
||||
shareId: string;
|
||||
};
|
||||
file: Readable;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { ShareService } from "src/share/share.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
constructor(
|
||||
private _shareService: ShareService,
|
||||
private _prisma: PrismaService,
|
||||
_config: ConfigService,
|
||||
) {
|
||||
super(_shareService, _prisma);
|
||||
super(_shareService, _prisma, _config);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
|
||||
174
backend/src/file/local.service.ts
Normal file
174
backend/src/file/local.service.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { validate as isValidUUID } from "uuid";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { Readable } from "stream";
|
||||
|
||||
@Injectable()
|
||||
export class LocalFileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) {
|
||||
file.id = crypto.randomUUID();
|
||||
} else if (!isValidUUID(file.id)) {
|
||||
throw new BadRequestException("Invalid file ID format");
|
||||
}
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { files: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (share.uploadLocked)
|
||||
throw new BadRequestException("Share is already completed");
|
||||
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`)
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
}
|
||||
|
||||
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||
const chunkSize = this.config.get("share.chunkSize");
|
||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
throw new BadRequestException({
|
||||
message: "Unexpected chunk received",
|
||||
error: "unexpected_chunk_index",
|
||||
expectedChunkIndex,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if there is enough space on the server
|
||||
const space = await fs.statfs(SHARE_DIRECTORY);
|
||||
const availableSpace = space.bavail * space.bsize;
|
||||
if (availableSpace < buffer.byteLength) {
|
||||
throw new InternalServerErrorException("Not enough space on the server");
|
||||
}
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0,
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
shareSizeSum > this.config.get("share.maxSize") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.appendFile(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
await fs.rename(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = (
|
||||
await fs.stat(`${SHARE_DIRECTORY}/${shareId}/${file.id}`)
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
mimeType: mime.contentType(fileMetaData.name.split(".").pop()),
|
||||
...fileMetaData,
|
||||
size: fileMetaData.size,
|
||||
},
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
await fs.unlink(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getZip(shareId: string): Promise<Readable> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const zipStream = createReadStream(
|
||||
`${SHARE_DIRECTORY}/${shareId}/archive.zip`,
|
||||
);
|
||||
|
||||
zipStream.on("error", (err) => {
|
||||
reject(new InternalServerErrorException(err));
|
||||
});
|
||||
|
||||
zipStream.on("open", () => {
|
||||
resolve(zipStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
390
backend/src/file/s3.service.ts
Normal file
390
backend/src/file/s3.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
UploadPartCommandOutput,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import * as crypto from "crypto";
|
||||
import * as mime from "mime-types";
|
||||
import { File } from "./file.service";
|
||||
import { Readable } from "stream";
|
||||
import { validate as isValidUUID } from "uuid";
|
||||
import * as archiver from "archiver";
|
||||
|
||||
@Injectable()
|
||||
export class S3FileService {
|
||||
private readonly logger = new Logger(S3FileService.name);
|
||||
|
||||
private multipartUploads: Record<
|
||||
string,
|
||||
{
|
||||
uploadId: string;
|
||||
parts: Array<{ ETag: string | undefined; PartNumber: number }>;
|
||||
}
|
||||
> = {};
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) {
|
||||
file.id = crypto.randomUUID();
|
||||
} else if (!isValidUUID(file.id)) {
|
||||
throw new BadRequestException("Invalid file ID format");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
const key = `${this.getS3Path()}${shareId}/${file.name}`;
|
||||
const bucketName = this.config.get("s3.bucketName");
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// Initialize multipart upload if it's the first chunk
|
||||
if (chunk.index === 0) {
|
||||
const multipartInitResponse = await s3Instance.send(
|
||||
new CreateMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const uploadId = multipartInitResponse.UploadId;
|
||||
if (!uploadId) {
|
||||
throw new Error("Failed to initialize multipart upload.");
|
||||
}
|
||||
|
||||
// Store the uploadId and parts list in memory
|
||||
this.multipartUploads[file.id] = {
|
||||
uploadId,
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get the ongoing multipart upload
|
||||
const multipartUpload = this.multipartUploads[file.id];
|
||||
if (!multipartUpload) {
|
||||
throw new InternalServerErrorException(
|
||||
"Multipart upload session not found.",
|
||||
);
|
||||
}
|
||||
|
||||
const uploadId = multipartUpload.uploadId;
|
||||
|
||||
// Upload the current chunk
|
||||
const partNumber = chunk.index + 1; // Part numbers start from 1
|
||||
|
||||
const uploadPartResponse: UploadPartCommandOutput = await s3Instance.send(
|
||||
new UploadPartCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
PartNumber: partNumber,
|
||||
UploadId: uploadId,
|
||||
Body: buffer,
|
||||
}),
|
||||
);
|
||||
|
||||
// Store the ETag and PartNumber for later completion
|
||||
multipartUpload.parts.push({
|
||||
ETag: uploadPartResponse.ETag,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
// Complete the multipart upload if it's the last chunk
|
||||
if (chunk.index === chunk.total - 1) {
|
||||
await s3Instance.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: multipartUpload.parts,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Remove the completed upload from memory
|
||||
delete this.multipartUploads[file.id];
|
||||
}
|
||||
} catch (error) {
|
||||
// Abort the multipart upload if it fails
|
||||
const multipartUpload = this.multipartUploads[file.id];
|
||||
if (multipartUpload) {
|
||||
try {
|
||||
await s3Instance.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
UploadId: multipartUpload.uploadId,
|
||||
}),
|
||||
);
|
||||
} catch (abortError) {
|
||||
console.error("Error aborting multipart upload:", abortError);
|
||||
}
|
||||
delete this.multipartUploads[file.id];
|
||||
}
|
||||
this.logger.error(error);
|
||||
throw new Error("Multipart upload failed. The upload has been aborted.");
|
||||
}
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
const fileSize: number = await this.getFileSize(shareId, file.name);
|
||||
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: fileSize.toString(),
|
||||
share: { connect: { id: shareId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
async get(shareId: string, fileId: string): Promise<File> {
|
||||
const fileName = (
|
||||
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||
).name;
|
||||
|
||||
const s3Instance = this.getS3Instance();
|
||||
const key = `${this.getS3Path()}${shareId}/${fileName}`;
|
||||
const response = await s3Instance.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
id: fileId,
|
||||
size: response.ContentLength?.toString() || "0",
|
||||
name: fileName,
|
||||
shareId: shareId,
|
||||
createdAt: response.LastModified || new Date(),
|
||||
mimeType:
|
||||
mime.contentType(fileId.split(".").pop()) ||
|
||||
"application/octet-stream",
|
||||
},
|
||||
file: response.Body as Readable,
|
||||
} as File;
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const key = `${this.getS3Path()}${shareId}/${fileMetaData.name}`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
await s3Instance.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error("Could not delete file from S3");
|
||||
}
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
const prefix = `${this.getS3Path()}${shareId}/`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// List all objects under the given prefix
|
||||
const listResponse = await s3Instance.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Prefix: prefix,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
||||
throw new Error(`No files found for share ${shareId}`);
|
||||
}
|
||||
|
||||
// Extract the keys of the files to be deleted
|
||||
const objectsToDelete = listResponse.Contents.map((file) => ({
|
||||
Key: file.Key!,
|
||||
}));
|
||||
|
||||
// Delete all files in a single request (up to 1000 objects at once)
|
||||
await s3Instance.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Delete: {
|
||||
Objects: objectsToDelete,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error("Could not delete all files from S3");
|
||||
}
|
||||
}
|
||||
|
||||
async getFileSize(shareId: string, fileName: string): Promise<number> {
|
||||
const key = `${this.getS3Path()}${shareId}/${fileName}`;
|
||||
const s3Instance = this.getS3Instance();
|
||||
|
||||
try {
|
||||
// Get metadata of the file using HeadObjectCommand
|
||||
const headObjectResponse = await s3Instance.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.config.get("s3.bucketName"),
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
// Return ContentLength which is the file size in bytes
|
||||
return headObjectResponse.ContentLength ?? 0;
|
||||
} catch (error) {
|
||||
throw new Error("Could not retrieve file size");
|
||||
}
|
||||
}
|
||||
|
||||
getS3Instance(): S3Client {
|
||||
const checksumCalculation =
|
||||
this.config.get("s3.useChecksum") === true ? null : "WHEN_REQUIRED";
|
||||
|
||||
return new S3Client({
|
||||
endpoint: this.config.get("s3.endpoint"),
|
||||
region: this.config.get("s3.region"),
|
||||
credentials: {
|
||||
accessKeyId: this.config.get("s3.key"),
|
||||
secretAccessKey: this.config.get("s3.secret"),
|
||||
},
|
||||
forcePathStyle: true,
|
||||
requestChecksumCalculation: checksumCalculation,
|
||||
responseChecksumValidation: checksumCalculation,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return new Promise<Readable>(async (resolve, reject) => {
|
||||
const s3Instance = this.getS3Instance();
|
||||
const bucketName = this.config.get("s3.bucketName");
|
||||
const compressionLevel = this.config.get("share.zipCompressionLevel");
|
||||
|
||||
const prefix = `${this.getS3Path()}${shareId}/`;
|
||||
|
||||
try {
|
||||
const listResponse = await s3Instance.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
Prefix: prefix,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
||||
throw new NotFoundException(`No files found for share ${shareId}`);
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: parseInt(compressionLevel) },
|
||||
});
|
||||
|
||||
archive.on("error", (err) => {
|
||||
this.logger.error("Archive error", err);
|
||||
reject(new InternalServerErrorException("Error creating ZIP file"));
|
||||
});
|
||||
|
||||
const fileKeys = listResponse.Contents.filter(
|
||||
(object) => object.Key && object.Key !== prefix,
|
||||
).map((object) => object.Key as string);
|
||||
|
||||
if (fileKeys.length === 0) {
|
||||
throw new NotFoundException(
|
||||
`No valid files found for share ${shareId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let filesAdded = 0;
|
||||
|
||||
const processNextFile = async (index: number) => {
|
||||
if (index >= fileKeys.length) {
|
||||
archive.finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = fileKeys[index];
|
||||
const fileName = key.replace(prefix, "");
|
||||
|
||||
try {
|
||||
const response = await s3Instance.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.Body instanceof Readable) {
|
||||
const fileStream = response.Body;
|
||||
|
||||
fileStream.on("end", () => {
|
||||
filesAdded++;
|
||||
processNextFile(index + 1);
|
||||
});
|
||||
|
||||
fileStream.on("error", (err) => {
|
||||
this.logger.error(`Error streaming file ${fileName}`, err);
|
||||
processNextFile(index + 1);
|
||||
});
|
||||
|
||||
archive.append(fileStream, { name: fileName });
|
||||
} else {
|
||||
processNextFile(index + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing file ${fileName}`, error);
|
||||
processNextFile(index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
resolve(archive);
|
||||
processNextFile(0);
|
||||
} catch (error) {
|
||||
this.logger.error("Error creating ZIP file", error);
|
||||
|
||||
reject(new InternalServerErrorException("Error creating ZIP file"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getS3Path(): string {
|
||||
const configS3Path = this.config.get("s3.bucketPath");
|
||||
return configS3Path ? `${configS3Path}/` : "";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
LogLevel,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
@@ -12,10 +13,35 @@ import { NextFunction, Request, Response } from "express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
import {
|
||||
DATA_DIRECTORY,
|
||||
LOG_LEVEL_AVAILABLE,
|
||||
LOG_LEVEL_DEFAULT,
|
||||
LOG_LEVEL_ENV,
|
||||
} from "./constants";
|
||||
|
||||
function generateNestJsLogLevels(): LogLevel[] {
|
||||
if (LOG_LEVEL_ENV) {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_ENV as any);
|
||||
if (levelIndex === -1) {
|
||||
throw new Error(`log level ${LOG_LEVEL_ENV} unknown`);
|
||||
}
|
||||
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
} else {
|
||||
const levelIndex = LOG_LEVEL_AVAILABLE.indexOf(LOG_LEVEL_DEFAULT);
|
||||
return LOG_LEVEL_AVAILABLE.slice(levelIndex, LOG_LEVEL_AVAILABLE.length);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const logLevels = generateNestJsLogLevels();
|
||||
Logger.log(`Showing ${logLevels.join(", ")} messages`);
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: logLevels,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
@@ -48,7 +74,9 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||
await app.listen(
|
||||
parseInt(process.env.BACKEND_PORT || process.env.PORT || "8080"),
|
||||
);
|
||||
|
||||
const logger = new Logger("UnhandledAsyncError");
|
||||
process.on("unhandledRejection", (e) => logger.error(e));
|
||||
|
||||
@@ -4,4 +4,5 @@ export interface OAuthSignInDto {
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
idToken?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { OAuthController } from "./oauth.controller";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
@@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
|
||||
inject: ["OAUTH_PROVIDERS"],
|
||||
},
|
||||
],
|
||||
imports: [AuthModule],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
@@ -6,14 +6,17 @@ import { ConfigService } from "../config/config.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
private auth: AuthService,
|
||||
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
@Inject("OAUTH_PROVIDERS")
|
||||
private oAuthProviders: Record<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
@@ -27,6 +30,18 @@ export class OAuthService {
|
||||
.map(([platform, _]) => platform);
|
||||
}
|
||||
|
||||
availableProviders(): Record<string, OAuthProvider<unknown>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.oAuthProviders)
|
||||
.map(([providerName, provider]) => [
|
||||
[providerName, provider],
|
||||
this.config.get(`oauth.${providerName}-enabled`),
|
||||
])
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([provider, _]) => provider),
|
||||
);
|
||||
}
|
||||
|
||||
async status(user: User) {
|
||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
||||
select: {
|
||||
@@ -48,14 +63,14 @@ export class OAuthService {
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.updateIsAdmin(user);
|
||||
await this.updateIsAdmin(oauthUser.userId, user.isAdmin);
|
||||
const updatedUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
id: oauthUser.userId,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.auth.generateToken(updatedUser, true);
|
||||
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
return this.signUp(user, ip);
|
||||
@@ -108,8 +123,10 @@ export class OAuthService {
|
||||
}
|
||||
|
||||
private async getAvailableUsername(preferredUsername: string) {
|
||||
// only remove + and - from preferred username for now (maybe not enough)
|
||||
let username = preferredUsername.replace(/[+-]/g, "").substring(0, 20);
|
||||
// Only keep letters, numbers, dots, and underscores. Truncate to 20 characters.
|
||||
let username = preferredUsername
|
||||
.replace(/[^a-zA-Z0-9._]/g, "")
|
||||
.substring(0, 20);
|
||||
while (true) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -153,8 +170,8 @@ export class OAuthService {
|
||||
userId: existingUser.id,
|
||||
},
|
||||
});
|
||||
await this.updateIsAdmin(user);
|
||||
return this.auth.generateToken(existingUser, true);
|
||||
await this.updateIsAdmin(existingUser.id, user.isAdmin);
|
||||
return this.auth.generateToken(existingUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
const result = await this.auth.signUp(
|
||||
@@ -179,15 +196,15 @@ export class OAuthService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateIsAdmin(user: OAuthSignInDto) {
|
||||
if ("isAdmin" in user)
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
data: {
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
private async updateIsAdmin(userId: string, isAdmin?: boolean) {
|
||||
if (!isAdmin) return;
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
isAdmin: isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,17 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
if (guild) {
|
||||
await this.checkLimitedGuild(token, guild);
|
||||
}
|
||||
const limitedUsers = this.config.get("oauth.discord-limitedUsers");
|
||||
if (limitedUsers) {
|
||||
await this.checkLimitedUsers(user, limitedUsers);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "discord",
|
||||
providerId: user.id,
|
||||
providerUsername: user.global_name ?? user.username,
|
||||
email: user.email,
|
||||
idToken: `discord:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +112,12 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
async checkLimitedUsers(user: DiscordUser, userIds: string) {
|
||||
if (!userIds.split(",").includes(user.id)) {
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscordToken {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Cache } from "cache-manager";
|
||||
import * as jmespath from "jmespath";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
protected discoveryUri: string;
|
||||
@@ -70,7 +70,10 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
scope:
|
||||
this.name == "oidc"
|
||||
? this.config.get(`oauth.oidc-scope`)
|
||||
: "openid email profile",
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
state,
|
||||
nonce,
|
||||
@@ -116,7 +119,13 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
},
|
||||
): Promise<OAuthSignInDto> {
|
||||
const idTokenData = this.decodeIdToken(token.idToken);
|
||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
||||
|
||||
if (!idTokenData) {
|
||||
this.logger.error(
|
||||
`Can not get ID Token from response ${JSON.stringify(token.rawToken, undefined, 2)}`,
|
||||
);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
const key = `oauth-${this.name}-nonce-${query.state}`;
|
||||
const nonce = await this.cache.get(key);
|
||||
@@ -133,38 +142,41 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
: idTokenData.preferred_username ||
|
||||
idTokenData.name ||
|
||||
idTokenData.nickname;
|
||||
|
||||
|
||||
let isAdmin: boolean;
|
||||
|
||||
|
||||
if (roleConfig?.path) {
|
||||
// A path to read roles from the token is configured
|
||||
let roles: string[] | null;
|
||||
let roles: string[] = [];
|
||||
try {
|
||||
roles = jmespath.search(idTokenData, roleConfig.path);
|
||||
const rolesClaim = jmespath.search(idTokenData, roleConfig.path);
|
||||
if (Array.isArray(rolesClaim)) {
|
||||
roles = rolesClaim;
|
||||
}
|
||||
} catch (e) {
|
||||
roles = null;
|
||||
}
|
||||
if (Array.isArray(roles)) {
|
||||
// Roles are found in the token
|
||||
if (roleConfig.generalAccess && !roles.includes(roleConfig.generalAccess)) {
|
||||
// Role for general access is configured and the user does not have it
|
||||
this.logger.error(`User roles ${roles} do not include ${roleConfig.generalAccess}`);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
if (roleConfig.adminAccess) {
|
||||
// Role for admin access is configured
|
||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
this.logger.warn(
|
||||
`Roles not found at path ${roleConfig.path} in ID Token ${JSON.stringify(
|
||||
idTokenData,
|
||||
undefined,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
roleConfig.generalAccess &&
|
||||
!roles.includes(roleConfig.generalAccess)
|
||||
) {
|
||||
// Role for general access is configured and the user does not have it
|
||||
this.logger.error(
|
||||
`User roles ${roles} do not include ${roleConfig.generalAccess}`,
|
||||
);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
if (roleConfig.adminAccess) {
|
||||
// Role for admin access is configured
|
||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
@@ -186,6 +198,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
providerId: idTokenData.sub,
|
||||
providerUsername: username,
|
||||
...(isAdmin !== undefined && { isAdmin }),
|
||||
idToken: `${this.name}:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +253,8 @@ export interface OidcConfiguration {
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
scopes_supported?: string[];
|
||||
claims_supported?: string[];
|
||||
frontchannel_logout_supported?: boolean;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OidcJwk {
|
||||
|
||||
@@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||
providerId: user.id.toString(),
|
||||
providerUsername: user.name ?? user.login,
|
||||
email,
|
||||
idToken: `github:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ export class OidcProvider extends GenericOidcProvider {
|
||||
): Promise<OAuthSignInDto> {
|
||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
||||
const rolePath = this.config.get("oauth.oidc-rolePath") || undefined;
|
||||
const roleGeneralAccess = this.config.get("oauth.oidc-roleGeneralAccess") || undefined;
|
||||
const roleAdminAccess = this.config.get("oauth.oidc-roleAdminAccess") || undefined;
|
||||
const roleGeneralAccess =
|
||||
this.config.get("oauth.oidc-roleGeneralAccess") || undefined;
|
||||
const roleAdminAccess =
|
||||
this.config.get("oauth.oidc-roleAdminAccess") || undefined;
|
||||
return super.getUserInfo(token, query, claim, {
|
||||
path: rolePath,
|
||||
generalAccess: roleGeneralAccess,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
datasources: {
|
||||
@@ -12,6 +14,6 @@ export class PrismaService extends PrismaClient {
|
||||
},
|
||||
},
|
||||
});
|
||||
super.$connect().then(() => console.info("Connected to the database"));
|
||||
super.$connect().then(() => this.logger.log("Connected to the database"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,10 @@ export class CreateReverseShareDTO {
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
maxUseCount: number;
|
||||
|
||||
@IsBoolean()
|
||||
simplified: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
publicAccess: boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export class ReverseShareDTO {
|
||||
@Expose()
|
||||
token: string;
|
||||
|
||||
@Expose()
|
||||
simplified: boolean;
|
||||
|
||||
from(partial: Partial<ReverseShareDTO>) {
|
||||
return plainToClass(ReverseShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -26,10 +26,11 @@ export class ReverseShareService {
|
||||
.toDate();
|
||||
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
maxExpiration.value !== 0 &&
|
||||
parsedExpiration >
|
||||
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
@@ -49,6 +50,8 @@ export class ReverseShareService {
|
||||
remainingUses: data.maxUseCount,
|
||||
maxShareSize: data.maxShareSize,
|
||||
sendEmailNotification: data.sendEmailNotification,
|
||||
simplified: data.simplified,
|
||||
publicAccess: data.publicAccess,
|
||||
creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
import { FileDTO } from "../../file/dto/file.dto";
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { MyShareSecurityDTO } from "./myShareSecurity.dto";
|
||||
|
||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
@@ -21,6 +22,9 @@ export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
|
||||
files: Omit<FileDTO, "share" | "from">[];
|
||||
|
||||
@Expose()
|
||||
security?: MyShareSecurityDTO;
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
9
backend/src/share/dto/myShareSecurity.dto.ts
Normal file
9
backend/src/share/dto/myShareSecurity.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Expose } from "class-transformer";
|
||||
|
||||
export class MyShareSecurityDTO {
|
||||
@Expose()
|
||||
passwordProtected: boolean;
|
||||
|
||||
@Expose()
|
||||
maxViews: number;
|
||||
}
|
||||
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
19
backend/src/share/dto/shareComplete.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
|
||||
export class CompletedShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
notifyReverseShareCreator?: boolean;
|
||||
|
||||
from(partial: Partial<CompletedShareDTO>) {
|
||||
return plainToClass(CompletedShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
fromList(partial: Partial<CompletedShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(CompletedShareDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
@@ -9,13 +8,19 @@ import { Request } from "express";
|
||||
import * as moment from "moment";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ShareService } from "src/share/share.service";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class ShareSecurityGuard implements CanActivate {
|
||||
export class ShareSecurityGuard extends JwtGuard {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
@@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true },
|
||||
include: { security: true, reverseShare: true },
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -53,6 +58,22 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
"share_token_required",
|
||||
);
|
||||
|
||||
// Run the JWTGuard to set the user
|
||||
await super.canActivate(context);
|
||||
const user = request.user as User;
|
||||
|
||||
// Only the creator and reverse share creator can access the reverse share if it's not public
|
||||
if (
|
||||
share.reverseShare &&
|
||||
!share.reverseShare.publicAccess &&
|
||||
share.creatorId !== user?.id &&
|
||||
share.reverseShare.creatorId !== user?.id
|
||||
)
|
||||
throw new ForbiddenException(
|
||||
"Only reverse share creator can access this share",
|
||||
"private_share",
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
|
||||
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
||||
import { ShareService } from "./share.service";
|
||||
import { CompletedShareDTO } from "./dto/shareComplete.dto";
|
||||
@Controller("shares")
|
||||
export class ShareController {
|
||||
constructor(
|
||||
@@ -86,7 +87,7 @@ export class ShareController {
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new ShareDTO().from(
|
||||
return new CompletedShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
export class ShareService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private fileService: FileService,
|
||||
private emailService: EmailService,
|
||||
private config: ConfigService,
|
||||
@@ -55,13 +56,12 @@ export class ShareService {
|
||||
|
||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
||||
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
maxExpiration.value !== 0 &&
|
||||
(expiresNever ||
|
||||
parsedExpiration >
|
||||
moment()
|
||||
.add(this.config.get("share.maxExpiration"), "hours")
|
||||
.toDate())
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
@@ -86,6 +86,7 @@ export class ShareService {
|
||||
? share.recipients.map((email) => ({ email }))
|
||||
: [],
|
||||
},
|
||||
storageProvider: this.configService.get("s3.enabled") ? "S3" : "LOCAL",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +106,8 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
if (this.config.get("s3.enabled")) return;
|
||||
|
||||
const path = `${SHARE_DIRECTORY}/${shareId}`;
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
@@ -159,11 +162,12 @@ export class ShareService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
share.reverseShare &&
|
||||
this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
) {
|
||||
const notifyReverseShareCreator = share.reverseShare
|
||||
? this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
: undefined;
|
||||
|
||||
if (notifyReverseShareCreator) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
share.reverseShare.creator.email,
|
||||
share.id,
|
||||
@@ -180,10 +184,15 @@ export class ShareService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.share.update({
|
||||
const updatedShare = await this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedShare,
|
||||
notifyReverseShareCreator,
|
||||
};
|
||||
}
|
||||
|
||||
async revertComplete(id: string) {
|
||||
@@ -223,7 +232,7 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true, files: true },
|
||||
include: { recipients: true, files: true, security: true },
|
||||
});
|
||||
|
||||
return shares.map((share) => {
|
||||
@@ -231,6 +240,10 @@ export class ShareService {
|
||||
...share,
|
||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
security: {
|
||||
maxViews: share.security?.maxViews,
|
||||
passwordProtected: !!share.security?.password,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -239,7 +252,11 @@ export class ShareService {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: true,
|
||||
files: {
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
creator: true,
|
||||
security: true,
|
||||
},
|
||||
@@ -305,11 +322,21 @@ export class ShareService {
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
if (share?.security?.password) {
|
||||
if (!password) {
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required",
|
||||
);
|
||||
}
|
||||
|
||||
const isPasswordValid = await argon.verify(
|
||||
share.security.password,
|
||||
password,
|
||||
);
|
||||
if (!isPasswordValid) {
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
@@ -325,12 +352,13 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async generateShareToken(shareId: string) {
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
const tokenPayload = {
|
||||
shareId,
|
||||
shareCreatedAt: moment(createdAt).unix(),
|
||||
iat: moment().unix(),
|
||||
};
|
||||
|
||||
@@ -346,7 +374,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async verifyShareToken(shareId: string, token: string) {
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
@@ -357,7 +385,10 @@ export class ShareService {
|
||||
ignoreExpiration: moment(expiration).isSame(0),
|
||||
});
|
||||
|
||||
return claims.shareId == shareId;
|
||||
return (
|
||||
claims.shareId == shareId &&
|
||||
claims.shareCreatedAt == moment(createdAt).unix()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,16 +25,23 @@ export class UserDTO {
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
|
||||
@Expose()
|
||||
isLdap: boolean;
|
||||
|
||||
ldapDN?: string;
|
||||
|
||||
@Expose()
|
||||
totpVerified: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||
const result = plainToClass(UserDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
result.isLdap = partial.ldapDN?.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
fromList(partial: Partial<UserDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
return partial.map((part) => this.from(part));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -14,6 +15,7 @@ import { Response } from "express";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
@@ -22,7 +24,10 @@ import { UserSevice } from "./user.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
constructor(private userService: UserSevice) {}
|
||||
constructor(
|
||||
private userService: UserSevice,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
// Own user operations
|
||||
@Get("me")
|
||||
@@ -44,18 +49,26 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@HttpCode(204)
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
await this.userService.delete(user.id);
|
||||
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
|
||||
response.cookie("access_token", "accessToken", {
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
response.cookie("refresh_token", "", {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
maxAge: -1,
|
||||
secure: isSecure,
|
||||
});
|
||||
return new UserDTO().from(await this.userService.delete(user.id));
|
||||
}
|
||||
|
||||
// Global user operations
|
||||
|
||||
@@ -8,5 +8,6 @@ import { FileModule } from "src/file/file.module";
|
||||
imports: [EmailModule, FileModule],
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
exports: [UserSevice],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { Entry } from "ldapts";
|
||||
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { inspect } from "util";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { FileService } from "../file/file.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
private readonly logger = new Logger(UserSevice.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
private fileService: FileService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
@@ -82,10 +89,140 @@ export class UserSevice {
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
if (user.isAdmin) {
|
||||
const userCount = await this.prisma.user.count({
|
||||
where: { isAdmin: true },
|
||||
});
|
||||
|
||||
if (userCount === 1) {
|
||||
throw new BadRequestException("Cannot delete the last admin user");
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
user.shares.map((share) => this.fileService.deleteAllFiles(share.id)),
|
||||
);
|
||||
|
||||
return await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findOrCreateFromLDAP(
|
||||
providedCredentials: AuthSignInDTO,
|
||||
ldapEntry: Entry,
|
||||
) {
|
||||
const fieldNameMemberOf = this.configService.get("ldap.fieldNameMemberOf");
|
||||
const fieldNameEmail = this.configService.get("ldap.fieldNameEmail");
|
||||
|
||||
let isAdmin = false;
|
||||
if (fieldNameMemberOf in ldapEntry) {
|
||||
const adminGroup = this.configService.get("ldap.adminGroups");
|
||||
const entryGroups = Array.isArray(ldapEntry[fieldNameMemberOf])
|
||||
? ldapEntry[fieldNameMemberOf]
|
||||
: [ldapEntry[fieldNameMemberOf]];
|
||||
isAdmin = entryGroups.includes(adminGroup) ?? false;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the member field ${fieldNameMemberOf} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
let userEmail: string | null = null;
|
||||
if (fieldNameEmail in ldapEntry) {
|
||||
const value = Array.isArray(ldapEntry[fieldNameEmail])
|
||||
? ldapEntry[fieldNameEmail][0]
|
||||
: ldapEntry[fieldNameEmail];
|
||||
if (value) {
|
||||
userEmail = value.toString();
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Trying to create/update a ldap user but the email field ${fieldNameEmail} is not present.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (providedCredentials.email) {
|
||||
/* if LDAP does not provides an users email address, take the user provided email address instead */
|
||||
userEmail = providedCredentials.email;
|
||||
}
|
||||
|
||||
const randomId = crypto.randomUUID();
|
||||
const placeholderUsername = `ldap_user_${randomId}`;
|
||||
const placeholderEMail = `${randomId}@ldap.local`;
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.upsert({
|
||||
create: {
|
||||
username: providedCredentials.username ?? placeholderUsername,
|
||||
email: userEmail ?? placeholderEMail,
|
||||
password: await argon.hash(crypto.randomUUID()),
|
||||
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
update: {
|
||||
isAdmin,
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
where: {
|
||||
ldapDN: ldapEntry.dn,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.username === placeholderUsername) {
|
||||
/* Give the user a human readable name if the user has been created with a placeholder username */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
username: `user_${user.id}`,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
user.username = newUser.username;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(
|
||||
`Failed to update users ${user.id} placeholder username: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (userEmail && userEmail !== user.email) {
|
||||
/* Sync users email if it has changed */
|
||||
await this.prisma.user
|
||||
.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: userEmail,
|
||||
},
|
||||
})
|
||||
.then((newUser) => {
|
||||
this.logger.log(
|
||||
`Updated users ${user.id} email from ldap from ${user.email} to ${userEmail}.`,
|
||||
);
|
||||
user.email = newUser.email;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Failed to update users ${user.id} email to ${userEmail}: ${inspect(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,3 +10,20 @@ export function parseRelativeDateToAbsolute(relativeDate: string) {
|
||||
)
|
||||
.toDate();
|
||||
}
|
||||
|
||||
type Timespan = {
|
||||
value: number;
|
||||
unit: "minutes" | "hours" | "days" | "weeks" | "months" | "years";
|
||||
};
|
||||
|
||||
export function stringToTimespan(value: string): Timespan {
|
||||
const [time, unit] = value.split(" ");
|
||||
return {
|
||||
value: parseInt(time),
|
||||
unit: unit as Timespan["unit"],
|
||||
};
|
||||
}
|
||||
|
||||
export function timespanToString(timespan: Timespan) {
|
||||
return `${timespan.value} ${timespan.unit}`;
|
||||
}
|
||||
|
||||
237
config.example.yaml
Normal file
237
config.example.yaml
Normal file
@@ -0,0 +1,237 @@
|
||||
#This configuration is pre-filled with the default values.
|
||||
#You can remove keys you don't want to set. If a key is missing, the value set in the UI will be used; if that is also unset, the default value applies.
|
||||
|
||||
general:
|
||||
#Name of the application
|
||||
appName: Pingvin Share
|
||||
#On which URL Pingvin Share is available
|
||||
appUrl: http://localhost:3000
|
||||
#Whether to set the secure flag on cookies. If enabled, the site will not function when accessed over HTTP.
|
||||
secureCookies: "false"
|
||||
#Whether to show the home page
|
||||
showHomePage: "true"
|
||||
#Time after which a user must log in again (default: 3 months).
|
||||
sessionDuration: 3 months
|
||||
share:
|
||||
#Whether registration is allowed
|
||||
allowRegistration: "true"
|
||||
#Whether unauthenticated users can create shares
|
||||
allowUnauthenticatedShares: "false"
|
||||
#Maximum share expiration. Set to 0 to allow unlimited expiration.
|
||||
maxExpiration: 0 days
|
||||
#Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.
|
||||
shareIdLength: "8"
|
||||
#Maximum share size
|
||||
maxSize: "1000000000"
|
||||
#Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression.
|
||||
zipCompressionLevel: "9"
|
||||
#Adjust the chunk size for your uploads to balance efficiency and reliability according to your internet connection. Smaller chunks can enhance success rates for unstable connections, while larger chunks make uploads faster for stable connections.
|
||||
chunkSize: "10000000"
|
||||
#The share creation modal automatically appears when a user selects files, eliminating the need to manually click the button.
|
||||
autoOpenShareModal: "false"
|
||||
cache:
|
||||
#Normally Pingvin Share caches information in memory. If you run multiple instances of Pingvin Share, you need to enable Redis caching to share the cache between the instances.
|
||||
redis-enabled: "false"
|
||||
#Url to connect to the Redis instance used for caching.
|
||||
redis-url: redis://pingvin-redis:6379
|
||||
#Time in second to keep information inside the cache.
|
||||
ttl: "60"
|
||||
#Maximum number of items inside the cache.
|
||||
maxItems: "1000"
|
||||
email:
|
||||
#Whether to allow email sharing with recipients. Only enable this if SMTP is activated.
|
||||
enableShareEmailRecipients: "false"
|
||||
#Subject of the email which gets sent to the share recipients.
|
||||
shareRecipientsSubject: Files shared with you
|
||||
#Message which gets sent to the share recipients. Available variables:
|
||||
# {creator} - The username of the creator of the share
|
||||
# {creatorEmail} - The email of the creator of the share
|
||||
# {shareUrl} - The URL of the share
|
||||
# {desc} - The description of the share
|
||||
# {expires} - The expiration date of the share
|
||||
# These variables will be replaced with the actual value.
|
||||
shareRecipientsMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
{creator} ({creatorEmail}) shared some files with you. You can view or download the
|
||||
files with this link: {shareUrl}
|
||||
|
||||
|
||||
The share will expire {expires}.
|
||||
|
||||
|
||||
Note: {desc}
|
||||
|
||||
|
||||
Shared securely with Pingvin Share 🐧
|
||||
#Subject of the sent email when someone created a share with your reverse share link.
|
||||
reverseShareSubject: Reverse share link used
|
||||
#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.
|
||||
reverseShareMessage: |-
|
||||
Hey!
|
||||
|
||||
A share was just created with your reverse share link: {shareUrl}
|
||||
|
||||
Shared securely with Pingvin Share 🐧
|
||||
#Subject of the sent email when a user requests a password reset.
|
||||
resetPasswordSubject: Pingvin Share password reset
|
||||
#Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.
|
||||
resetPasswordMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
You requested a password reset. Click this link to reset your password:
|
||||
{url}
|
||||
|
||||
The link expires in an hour.
|
||||
|
||||
|
||||
Pingvin Share 🐧
|
||||
#Subject of the sent email when an admin invites a user.
|
||||
inviteSubject: Pingvin Share invite
|
||||
#Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL, {email} with the email and {password} with the users password.
|
||||
inviteMessage: >-
|
||||
Hey!
|
||||
|
||||
|
||||
You were invited to Pingvin Share. Click this link to accept the invite:
|
||||
{url}
|
||||
|
||||
|
||||
You can use the email "{email}" and the password "{password}" to sign in.
|
||||
|
||||
|
||||
Pingvin Share 🐧
|
||||
smtp:
|
||||
#Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.
|
||||
enabled: "false"
|
||||
#Only set this to true if you need to trust self signed certificates.
|
||||
allowUnauthorizedCertificates: "false"
|
||||
#Host of the SMTP server
|
||||
host: ""
|
||||
#Port of the SMTP server
|
||||
port: "0"
|
||||
#Email address from which the emails get sent
|
||||
email: ""
|
||||
#Username of the SMTP server
|
||||
username: ""
|
||||
#Password of the SMTP server
|
||||
password: ""
|
||||
ldap:
|
||||
#Use LDAP authentication for user login
|
||||
enabled: "false"
|
||||
#URL of the LDAP server
|
||||
url: ""
|
||||
#Default user used to perform the user search
|
||||
bindDn: ""
|
||||
#Password used to perform the user search
|
||||
bindPassword: ""
|
||||
#Base location, where the user search will be performed
|
||||
searchBase: ""
|
||||
#The user query will be used to search the 'User base' for the LDAP user. %username% can be used as the placeholder for the user given input.
|
||||
searchQuery: ""
|
||||
#Group required for administrative access.
|
||||
adminGroups: ""
|
||||
#LDAP attribute name for the groups, an user is a member of. This is used when checking for the admin group.
|
||||
fieldNameMemberOf: memberOf
|
||||
#LDAP attribute name for the email of an user.
|
||||
fieldNameEmail: userPrincipalName
|
||||
oauth:
|
||||
#Allow users to register via social login
|
||||
allowRegistration: "true"
|
||||
#Whether to ignore TOTP when user using social login
|
||||
ignoreTotp: "true"
|
||||
#Whether to disable password login
|
||||
#Make sure that an OAuth provider is properly configured before activating this configuration to avoid being locked out.
|
||||
disablePassword: "false"
|
||||
#Whether GitHub login is enabled
|
||||
github-enabled: "false"
|
||||
#Client ID of the GitHub OAuth app
|
||||
github-clientId: ""
|
||||
#Client secret of the GitHub OAuth app
|
||||
github-clientSecret: ""
|
||||
#Whether Google login is enabled
|
||||
google-enabled: "false"
|
||||
#Client ID of the Google OAuth app
|
||||
google-clientId: ""
|
||||
#Client secret of the Google OAuth app
|
||||
google-clientSecret: ""
|
||||
#Whether Microsoft login is enabled
|
||||
microsoft-enabled: "false"
|
||||
#Tenant ID of the Microsoft OAuth app
|
||||
#common: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.
|
||||
#consumers: Only users with a personal Microsoft account can sign in to the application.
|
||||
#domain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.
|
||||
microsoft-tenant: common
|
||||
#Client ID of the Microsoft OAuth app
|
||||
microsoft-clientId: ""
|
||||
#Client secret of the Microsoft OAuth app
|
||||
microsoft-clientSecret: ""
|
||||
#Whether Discord login is enabled
|
||||
discord-enabled: "false"
|
||||
#Limit signing in to users in a specific server. Leave it blank to disable.
|
||||
discord-limitedGuild: ""
|
||||
#Limit signing in to specific users by their Discord ID. Leave it blank to disable.
|
||||
discord-limitedUsers: ""
|
||||
#Client ID of the Discord OAuth app
|
||||
discord-clientId: ""
|
||||
#Client secret of the Discord OAuth app
|
||||
discord-clientSecret: ""
|
||||
#Whether OpenID Connect login is enabled
|
||||
oidc-enabled: "false"
|
||||
#Discovery URI of the OpenID Connect OAuth app
|
||||
oidc-discoveryUri: ""
|
||||
#Whether the “Sign out” button will sign out from the OpenID Connect provider
|
||||
oidc-signOut: "false"
|
||||
#Scopes which should be requested from the OpenID Connect provider.
|
||||
oidc-scope: openid email profile
|
||||
#Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.
|
||||
oidc-usernameClaim: ""
|
||||
#Must be a valid JMES path referencing an array of roles. Managing access rights using OpenID Connect roles is only recommended if no other identity provider is configured and password login is disabled. Leave it blank if you don't know what this config is.
|
||||
oidc-rolePath: ""
|
||||
#Role required for general access. Must be present in a user’s roles for them to log in. Leave it blank if you don't know what this config is.
|
||||
oidc-roleGeneralAccess: ""
|
||||
#Role required for administrative access. Must be present in a user’s roles for them to access the admin panel. Leave it blank if you don't know what this config is.
|
||||
oidc-roleAdminAccess: ""
|
||||
#Client ID of the OpenID Connect OAuth app
|
||||
oidc-clientId: ""
|
||||
#Client secret of the OpenID Connect OAuth app
|
||||
oidc-clientSecret: ""
|
||||
s3:
|
||||
#Whether S3 should be used to store the shared files instead of the local file system.
|
||||
enabled: "false"
|
||||
#The URL of the S3 bucket.
|
||||
endpoint: ""
|
||||
#The region of the S3 bucket.
|
||||
region: ""
|
||||
#The name of the S3 bucket.
|
||||
bucketName: ""
|
||||
#The default path which should be used to store the files in the S3 bucket.
|
||||
bucketPath: ""
|
||||
#The key which allows you to access the S3 bucket.
|
||||
key: ""
|
||||
#The secret which allows you to access the S3 bucket.
|
||||
secret: ""
|
||||
#Turn off for backends that do not support checksum (e.g. B2).
|
||||
useChecksum: "true"
|
||||
legal:
|
||||
#Whether to show a link to imprint and privacy policy in the footer.
|
||||
enabled: "false"
|
||||
#The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.
|
||||
imprintText: ""
|
||||
#If you already have an imprint page you can link it here instead of using the text field.
|
||||
imprintUrl: ""
|
||||
#The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.
|
||||
privacyPolicyText: ""
|
||||
#If you already have a privacy policy page you can link it here instead of using the text field.
|
||||
privacyPolicyUrl: ""
|
||||
#This configuration is used to create the initial user when the application is started for the first time.
|
||||
#Make sure to change at least the password as soon as you log in!
|
||||
initUser:
|
||||
enabled: false
|
||||
username: admin
|
||||
email: admin@example.com
|
||||
password: my-secure-password
|
||||
isAdmin: true
|
||||
ldapDN: ""
|
||||
@@ -1,4 +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"
|
||||
pull_request_title: 'chore(translations): update translations via Crowdin'
|
||||
|
||||
12
docker-compose.local.yml
Normal file
12
docker-compose.local.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
pingvin-share:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3001:3000
|
||||
environment:
|
||||
- TRUST_PROXY=false
|
||||
volumes:
|
||||
- "./data:/opt/app/backend/data"
|
||||
- "./data/images:/opt/app/frontend/public/img"
|
||||
# - "./config.yaml:/opt/app/config.yaml"
|
||||
@@ -1,18 +1,15 @@
|
||||
services:
|
||||
pingvin-share:
|
||||
image: stonith404/pingvin-share
|
||||
image: stonith404/pingvin-share # or ghcr.io/stonith404/pingvin-share
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- TRUST_PROXY=false # Set to true if a reverse proxy is in front of the container
|
||||
volumes:
|
||||
- "./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.
|
||||
# depends_on:
|
||||
# clamav:
|
||||
# condition: service_healthy
|
||||
# Optional: Add ClamAV (see README.md)
|
||||
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||
# clamav:
|
||||
# restart: unless-stopped
|
||||
# image: clamav/clamav
|
||||
# - "./config.yaml:/opt/app/config.yaml" # Add this line, if you want to configure pingvin-share via config file and not via UI
|
||||
|
||||
# To add ClamAV, to scan your shares for malicious files,
|
||||
# see https://stonith404.github.io/pingvin-share/setup/integrations/#clamav-docker-only
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
_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.
|
||||
@@ -1,97 +0,0 @@
|
||||
_选择合适的语言阅读: [西班牙语](/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` 来执行系统测试
|
||||
@@ -1,129 +0,0 @@
|
||||
# <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), [日本語](/docs/README.ja-jp.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 realizada por No Solo Hacking (español)](https://www.youtube.com/watch?v=ocd4EpLTYkU)
|
||||
- [Reseña por DB Tech (inglés)](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ 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.
|
||||
@@ -1,158 +0,0 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_READMEを別の言語で読む: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share は、セルフホスト型のファイル共有プラットフォームであり、WeTransfer、ギガファイル便などの代替プラットフォームです。
|
||||
|
||||
## ✨ 特徴的な機能
|
||||
|
||||
- リンクを用いたファイル共有
|
||||
- ファイルサイズ無制限 (ストレージスペースの範囲内で)
|
||||
- 共有への有効期限の設定
|
||||
- 訪問回数の制限とパスワードの設定により共有を安全に保つ
|
||||
- メールでリンクを共有
|
||||
- ClamAVと連携して、ウイルスチェックが可能
|
||||
|
||||
## 🐧 Pingvin Shareについて知る
|
||||
|
||||
- [デモ](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [DB Techによるレビュー](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ セットアップ
|
||||
|
||||
> 注意: Pingvin Shareは、早期段階であり、バグが含まれている場合があります。
|
||||
|
||||
### Dockerでインストール (おすすめ)
|
||||
|
||||
1. `docker-compose.yml`ファイルをダウンロード
|
||||
2. `docker-compose up -d`を実行
|
||||
|
||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
||||
|
||||
### スタンドアローンインストール
|
||||
|
||||
必要なツール:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) Pingvin Shareをバックグラウンドで動作させるために必要
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# 最新バージョンをチェックアウト
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# バックエンドを開始
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
#フロントエンドを開始
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
||||
|
||||
### 連携機能
|
||||
|
||||
#### ClamAV (Dockerのみ)
|
||||
|
||||
ClamAVは、共有されたファイルをスキャンし、感染したファイルを見つけた場合に削除するために使用されます。
|
||||
|
||||
1. ClamAVコンテナをDocker Composeの定義ファイル(`docker-compose.yml`を確認)に追加し、コンテナを開始してください。
|
||||
2. Dockerは、Pingvin Shareを開始する前に、ClamAVの準備が整うまで待機します。これには、1分から2分ほどかかります。
|
||||
3. Pingvin Shareのログに"ClamAV is active"というログが記録されます。
|
||||
|
||||
ClamAVは、非常に多くのリソースを必要とします、詳しくは[リソース](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)をご確認ください。
|
||||
|
||||
### 追加情報
|
||||
|
||||
- [Synology NASへのインストール方法](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### 新しいバージョンへのアップグレード
|
||||
|
||||
Pingvin Shareは早期段階のため、アップグレード前に必ずリリースノートを確認して、アップグレードしても問題ないかどうかご確認ください。
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### スタンドアローン
|
||||
|
||||
1. アプリを停止する
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. `git clone`のステップを除いて、[インストールガイド](#stand-alone-installation)をくり返してください。
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# 最新バージョンをチェックアウト
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# バックエンドを開始
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
#フロントエンドを開始
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### 設定
|
||||
|
||||
管理者のダッシュボード内の「設定」ページから、Pingvin Shareをカスタマイズできます。
|
||||
|
||||
#### 環境変数
|
||||
|
||||
インストール時の特定の設定で、環境変数を使用できます。次の環境変数が使用可能です:
|
||||
|
||||
##### バックエンド
|
||||
|
||||
| 変数名 | デフォルト値 | 説明 |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | バックエンドがリッスンするポート番号 |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | SQLiteのURL |
|
||||
| `DATA_DIRECTORY` | `./data` | データを保管するディレクトリ |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | ClamAVサーバーのIPアドレス |
|
||||
| `CLAMAV_PORT` | `3310` | ClamAVサーバーのポート番号 |
|
||||
|
||||
##### フロントエンド
|
||||
|
||||
| 変数名 | デフォルト値 | 説明 |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | フロントエンドがリッスンするポート番号 |
|
||||
| `API_URL` | `http://localhost:8080` | フロントエンドからアクセスするバックエンドへのURL |
|
||||
|
||||
## 🖤 コントリビュート
|
||||
|
||||
### 翻訳
|
||||
|
||||
Pingvin Shareをあなたが使用している言語に翻訳するお手伝いを募集しています。
|
||||
[Crowdin](https://crowdin.com/project/pingvin-share)上で、簡単にPingvin Shareの翻訳作業への参加が可能です。
|
||||
|
||||
あなたの言語がありませんか? 気軽に[リクエスト](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E)してください。
|
||||
|
||||
翻訳中に問題がありましたか? [ローカライズに関するディスカッション](https://github.com/stonith404/pingvin-share/discussions/198)に是非参加してください。
|
||||
|
||||
### プロジェクト
|
||||
|
||||
Pingvin Shareへのコントリビュートをいつでもお待ちしています! [コントリビューションガイド](/CONTRIBUTING.md)を確認して、是非参加してください。
|
||||
@@ -1,126 +0,0 @@
|
||||
# <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), [日本语](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share 是一个可自建的文件分享平台,是 WeTransfer 的一个替代品
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 通过可自定义后缀的链接分享文件
|
||||
- 可自定义任意大小的文件上传限制 (受制于托管所在的硬盘大小)
|
||||
- 对共享链接设置有效期限
|
||||
- 对共享链接设置访问次数和访问密码
|
||||
- 通过邮件自动发送共享链接
|
||||
- 整合 ClamAV 进行反病毒检查
|
||||
|
||||
## 🐧 了解一下 Pingvin Share
|
||||
|
||||
- [示例网站](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [DB Tech 推荐视频](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ 自建指南
|
||||
|
||||
> 注意:Pingvin Share 仍处于开发阶段并且可能存在 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) 来提交你的贡献
|
||||
3
docs/babel.config.js
Normal file
3
docs/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
91
docs/docs/help-out/contribute.md
Normal file
91
docs/docs/help-out/contribute.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Contributing
|
||||
|
||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
|
||||
## Getting started
|
||||
|
||||
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
||||
|
||||
## Submit a Pull Request
|
||||
|
||||
Before you submit the pull request for review please ensure that
|
||||
|
||||
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||
|
||||
`<type>[optional scope]: <description>`
|
||||
|
||||
example:
|
||||
|
||||
```
|
||||
feat(share): add password protection
|
||||
```
|
||||
|
||||
When `TYPE` can be:
|
||||
|
||||
- **feat** - is a new feature
|
||||
- **docs** - 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`
|
||||
|
||||
```
|
||||
$ git clone https://github.com/[your_username]/pingvin-share
|
||||
```
|
||||
|
||||
3. Work - commit - repeat
|
||||
|
||||
4. Push changes to GitHub
|
||||
|
||||
```
|
||||
$ git push origin [name_of_your_new_branch]
|
||||
```
|
||||
|
||||
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.
|
||||
6. Start a Pull Request
|
||||
7. Now submit the pull request and click on `Create pull request`.
|
||||
8. Get a code review approval/reject
|
||||
|
||||
</details>
|
||||
|
||||
## Setup project
|
||||
|
||||
Pingvin Share consists of a frontend and a backend.
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. Install the dependencies with `npm install`
|
||||
3. Push the database schema to the database by running `npx prisma db push`
|
||||
4. Seed the database with `npx prisma db seed`
|
||||
5. Start the backend with `npm run dev`
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Start the backend first
|
||||
2. Open the `frontend` folder
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
You're all set!
|
||||
|
||||
### Testing
|
||||
|
||||
At the moment we only have system tests for the backend. To run these tests, run `npm run test:system` in the backend folder.
|
||||
8
docs/docs/help-out/translate.md
Normal file
8
docs/docs/help-out/translate.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Translating
|
||||
|
||||
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).
|
||||
24
docs/docs/introduction.md
Normal file
24
docs/docs/introduction.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: introduction
|
||||
---
|
||||
|
||||
# Introduction
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## Features
|
||||
|
||||
- Share files using a link
|
||||
- Unlimited file size (restricted only by disk space)
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Integration with ClamAV for security scans
|
||||
|
||||
And more!
|
||||
|
||||
## Get to know Pingvin Share
|
||||
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
55
docs/docs/setup/configuration.md
Normal file
55
docs/docs/setup/configuration.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: configuration
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
## General configuration
|
||||
|
||||
There are plenty of settings you can adjust to your needs. Pingvin Share can be configured in two ways:
|
||||
|
||||
### UI
|
||||
|
||||
You can change the settings in the UI (`/admin/config`)
|
||||
|
||||
### YAML file
|
||||
|
||||
You can set the configuration via a YAML file. If you choose this way, you won't be able to change the settings in the UI.
|
||||
|
||||
If you use Docker you can create a `config.yml` file based on the [`config.example.yaml`](https://github.com/stonith404/pingvin-share/blob/main/config.example.yaml) and mount it to `/opt/app/config.yaml` in the container.
|
||||
|
||||
If you run Pingvin Share without Docker, you can create a `config.yml` file based on the [`config.example.yaml`](https://github.com/stonith404/pingvin-share/blob/main/config.example.yaml) in the root directory of the project.
|
||||
|
||||
---
|
||||
|
||||
### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
#### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `BACKEND_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. |
|
||||
| `CONFIG_FILE` | `../config.yaml` | Path to the configuration file |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` or `clamav` when running with Docker | The IP address of the ClamAV server. See the [ClamAV docs](integrations.md#clamav) for more information. |
|
||||
| `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. |
|
||||
|
||||
#### Docker specific
|
||||
|
||||
Environment variables that are only available when running Pingvin Share with Docker.
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TRUST_PROXY` | `false` | Whether Pingvin Share is behind a reverse proxy. If set to `true`, the `X-Forwarded-For` header is trusted. |
|
||||
| `CADDY_DISABLED` | `false` | Configures if Pingvin Share is starting built-in Caddy. If set to `true`, Caddy will not be started. If disabled, you must configure your reverse proxy to correctly map all paths. Refer to the [official Caddyfile](https://github.com/stonith404/pingvin-share/blob/main/reverse-proxy/Caddyfile) for guidance. |
|
||||
| `PUID` and `PGID` | `1000` | The user and group ID of the user who should run Pingvin Share inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
|
||||
55
docs/docs/setup/installation.md
Normal file
55
docs/docs/setup/installation.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: installation
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
1. Download the `docker-compose.yml` file
|
||||
2. Run `docker compose up -d`
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Installation with Portainer
|
||||
|
||||
1. In the **Stacks** menu, click the **Add stack** button
|
||||
2. Give you stack a name (ex. pingvinshare)
|
||||
3. In the web editor, paste the content of the [docker-compose](https://github.com/stonith404/pingvin-share/blob/main/docker-compose.yml) file.
|
||||
4. Edit the external port and the environment variables (optional).
|
||||
5. Click on **Deploy the stack**.
|
||||
|
||||
Your container is now listening on `http://localhost:<externalport>`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Stand-alone Installation
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
API_URL=http://localhost:8080 # Set the URL of the backend, default: http://localhost:8080
|
||||
pm2 start npm --name "pingvin-share-frontend" -- run start
|
||||
```
|
||||
|
||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Caddy can be found in `./reverse-proxy/Caddyfile`.
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
42
docs/docs/setup/integrations.md
Normal file
42
docs/docs/setup/integrations.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: integrations
|
||||
---
|
||||
|
||||
# Integrations
|
||||
|
||||
## ClamAV
|
||||
|
||||
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||
|
||||
Please note that ClamAV needs a lot of [resources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
### Docker
|
||||
|
||||
If you are already running ClamAV elsewhere, you can specify the `CLAMAV_HOST` environment variable to point to that instance.
|
||||
|
||||
Else you have to add the ClamAV container to the Pingvin Share Docker Compose stack:
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack and start the container.
|
||||
|
||||
```diff
|
||||
services:
|
||||
pingvin-share:
|
||||
image: stonith404/pingvin-share
|
||||
...
|
||||
+ depends_on:
|
||||
+ clamav:
|
||||
+ condition: service_healthy
|
||||
|
||||
+ clamav:
|
||||
+ restart: unless-stopped
|
||||
+ image: clamav/clamav
|
||||
|
||||
```
|
||||
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||
|
||||
### Stand-Alone
|
||||
|
||||
1. Install ClamAV
|
||||
2. Specify the `CLAMAV_HOST` environment variable for the backend and restart the Pingvin Share backend.
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
id: oauth2login
|
||||
---
|
||||
|
||||
# OAuth 2 Login Guide
|
||||
|
||||
## Config Built-in OAuth 2 Providers
|
||||
@@ -24,8 +28,7 @@ Redirect URL: `https://<your-domain>/api/oauth/callback/google`
|
||||
|
||||
Please follow the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) to register an application.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Microsoft Tenant** you set in the admin panel must match the **supported account types** you set in the Microsoft Entra admin center, otherwise the OAuth login will not work. Refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri) for more details.
|
||||
> [!IMPORTANT] > **Microsoft Tenant** you set in the admin panel must match the **supported account types** you set in the Microsoft Entra admin center, otherwise the OAuth login will not work. Refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri) for more details.
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/microsoft`
|
||||
|
||||
@@ -37,9 +40,11 @@ Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
|
||||
|
||||
### OpenID Connect
|
||||
|
||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik and Casdoor.
|
||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik, Casdoor and [Pocket ID](https://github.com/stonith404/pocket-id).
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/oidc`
|
||||
Redirect URI: `https://<your-domain>/api/oauth/callback/oidc`
|
||||
|
||||
Post Logout Redirect URI: `https://<your-domain>`
|
||||
|
||||
## Custom your OAuth 2 Provider
|
||||
|
||||
@@ -47,7 +52,7 @@ If our built-in providers don't meet your needs, you can create your own OAuth 2
|
||||
|
||||
### 1. Create config
|
||||
|
||||
Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts):
|
||||
Add your config (client id, client secret, etc.) in [`config.seed.ts`](https://github.com/stonith404/pingvin-share/blob/main/backend/prisma/seed/config.seed.ts):
|
||||
|
||||
```ts
|
||||
const configVariables: ConfigVariables = {
|
||||
@@ -67,17 +72,17 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Create provider class
|
||||
|
||||
#### Generic OpenID Connect
|
||||
|
||||
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
|
||||
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
|
||||
|
||||
The [Google provider](../backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples.
|
||||
The [Google provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/microsoft.provider.ts) are good examples.
|
||||
|
||||
Here are some discovery URIs for popular providers:
|
||||
|
||||
@@ -91,13 +96,13 @@ Here are some discovery URIs for popular providers:
|
||||
|
||||
#### OAuth 2
|
||||
|
||||
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
|
||||
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
|
||||
|
||||
The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples.
|
||||
The [GitHub provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/github.provider.ts) and [Discord provider](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/provider/discord.provider.ts) are good examples.
|
||||
|
||||
### 3. Register provider
|
||||
|
||||
Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts):
|
||||
Register your provider in [`OAuthModule`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](https://github.com/stonith404/pingvin-share/blob/main/backend/src/oauth/dto/oauthSignIn.dto.ts):
|
||||
|
||||
```ts
|
||||
@Module({
|
||||
@@ -106,23 +111,29 @@ Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts)
|
||||
// your provider
|
||||
{
|
||||
provide: "OAUTH_PROVIDERS",
|
||||
useFactory(github: GitHubProvider, /* your provider */): Record<string, OAuthProvider<unknown>> {
|
||||
useFactory(
|
||||
github: GitHubProvider /* your provider */
|
||||
): Record<string, OAuthProvider<unknown>> {
|
||||
return {
|
||||
github,
|
||||
/* your provider */
|
||||
};
|
||||
},
|
||||
inject: [GitHubProvider, /* your provider */],
|
||||
inject: [GitHubProvider /* your provider */],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class OAuthModule {
|
||||
}
|
||||
export class OAuthModule {}
|
||||
```
|
||||
|
||||
```ts
|
||||
export interface OAuthSignInDto {
|
||||
provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/;
|
||||
provider:
|
||||
| "github"
|
||||
| "google"
|
||||
| "microsoft"
|
||||
| "discord"
|
||||
| "oidc" /* your provider*/;
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
@@ -131,20 +142,20 @@ export interface OAuthSignInDto {
|
||||
|
||||
### 4. Add frontend icon
|
||||
|
||||
Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx).
|
||||
Add an icon in [`oauth.util.tsx`](https://github.com/stonith404/pingvin-share/blob/main/frontend/src/utils/oauth.util.tsx).
|
||||
|
||||
```tsx
|
||||
const getOAuthIcon = (provider: string) => {
|
||||
return {
|
||||
'github': <SiGithub />,
|
||||
github: <SiGithub />,
|
||||
/* your provider */
|
||||
}[provider];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Add i18n text
|
||||
|
||||
Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts).
|
||||
Add keys below to your i18n text in [locale file](https://github.com/stonith404/pingvin-share/blob/main/frontend/src/i18n/translations/en-US.ts).
|
||||
|
||||
- `signIn.oauth.YOUR_PROVIDER_NAME`
|
||||
- `account.card.oauth.YOUR_PROVIDER_NAME`
|
||||
32
docs/docs/setup/s3.md
Normal file
32
docs/docs/setup/s3.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: s3
|
||||
---
|
||||
|
||||
# S3
|
||||
|
||||
You are able to add your preferred S3 provider, like AWS, DigitalOcean, Exoscale or Infomaniak. However, if you don't
|
||||
want to store your files on a S3 bucket, you don't have to. Consider that this feature is `DISABLED` per default.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure your S3 provider and bucket by going to the configuration page in your admin dashboard `/admin/config/s3`.
|
||||
|
||||
| Key | Description | Value |
|
||||
|:-----------|:-------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------|
|
||||
| enabled | This property enables the storage location on your configured S3 bucket. | `true` |
|
||||
| endpoint | The host for your S3 bucket. Endpoint formats vary by provider and some may include the bucket name in the FQDN. Ensure this is configured correctly, as an incorrect value may break some features. | `sos-ch-dk-2.exo.io` |
|
||||
| region | This property is the region where the bucket is located. | `sos-ch-dk-2` |
|
||||
| bucketName | This property is the name of your S3 bucket. | `my-bucket` |
|
||||
| bucketPath | This property defines the folder where you want to store your files which are uploaded. Hint: Don't put a slash in the start or end. | `my/custom/path` (or leave it empty for root) |
|
||||
| key | This is the access key you need to access to your bucket. | `key-asdf` |
|
||||
| secret | This is the secret you need to access to your bucket. | `secret-asdf` |
|
||||
|
||||
Don't forget to save the configuration. :)
|
||||
|
||||
## ClamAV
|
||||
|
||||
Consider that ClamAV scans are not available at the moment if you store your files in a S3 bucket.
|
||||
|
||||
## ZIP
|
||||
|
||||
Creating ZIP archives is not currently supported if you store your files in an S3 bucket.
|
||||
49
docs/docs/setup/upgrading.md
Normal file
49
docs/docs/setup/upgrading.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: upgrading
|
||||
---
|
||||
|
||||
# Upgrading
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
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
|
||||
```
|
||||
### Portainer
|
||||
|
||||
1. In your container page, click on Recreate.
|
||||
2. Check the Re-Pull image toggle.
|
||||
3. Click on Recreate.
|
||||
|
||||
#### 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 install
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
Note that environment variables are not picked up when using pm2 restart, if you actually want to change configs, you need to run ````pm2 --update-env restart````
|
||||
64
docs/docusaurus.config.ts
Normal file
64
docs/docusaurus.config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type * as Preset from "@docusaurus/preset-classic";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
|
||||
const config: Config = {
|
||||
title: "Pingvin Share",
|
||||
tagline:
|
||||
"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.",
|
||||
favicon: "img/pingvinshare.svg",
|
||||
|
||||
url: "https://stonith404.github.io",
|
||||
baseUrl: "/pingvin-share/",
|
||||
organizationName: "stonith404",
|
||||
projectName: "pingvin-share",
|
||||
|
||||
onBrokenLinks: "warn",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
{
|
||||
docs: {
|
||||
routeBasePath: "/",
|
||||
sidebarPath: "./sidebars.ts",
|
||||
editUrl: "https://github.com/stonith404/pingvin-share/edit/main/docs",
|
||||
},
|
||||
blog: false,
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: "img/pingvinshare.svg",
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: "Pingvin Share",
|
||||
logo: {
|
||||
alt: "Pingvin Share Logo",
|
||||
src: "img/pingvinshare.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: "https://github.com/stonith404/pingvin-share",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
14671
docs/package-lock.json
generated
Normal file
14671
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
docs/package.json
Normal file
47
docs/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "pingvindocs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "GIT_USER=stonith404 docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.5.2",
|
||||
"@docusaurus/preset-classic": "3.5.2",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.5.2",
|
||||
"@docusaurus/tsconfig": "3.5.2",
|
||||
"@docusaurus/types": "3.5.2",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
76
docs/sidebars.ts
Normal file
76
docs/sidebars.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
|
||||
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
const sidebars: SidebarsConfig = {
|
||||
docsSidebar: [
|
||||
{
|
||||
type: "doc",
|
||||
id : "introduction",
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Getting Started",
|
||||
items: [
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/installation",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/configuration",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/integrations",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/oauth2login",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/s3",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "setup/upgrading",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Helping Out",
|
||||
items: [
|
||||
{
|
||||
type: "doc",
|
||||
id: "help-out/translate",
|
||||
},
|
||||
{
|
||||
type: "doc",
|
||||
id: "help-out/contribute",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
label: "Demo",
|
||||
href: "https://pingvin-share.dev.eliasschneider.com",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
label: "Discord",
|
||||
href: "https://discord.gg/HutpbfB59Q",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
6
docs/src/pages/index.tsx
Normal file
6
docs/src/pages/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
return <Redirect to='/pingvin-share/introduction' />;
|
||||
}
|
||||
BIN
docs/static/img/image.png
vendored
Normal file
BIN
docs/static/img/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
1
docs/static/img/pingvinshare.svg
vendored
Normal file
1
docs/static/img/pingvinshare.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1018 B |
7
docs/tsconfig.json
Normal file
7
docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -18,7 +18,4 @@ module.exports = withPWA({
|
||||
output: "standalone", env: {
|
||||
VERSION: version,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
apiURL: process.env.API_URL ?? 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
|
||||
3590
frontend/package-lock.json
generated
3590
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.28.0",
|
||||
"version": "1.13.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -9,7 +9,7 @@
|
||||
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
@@ -18,38 +18,37 @@
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/next": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"axios": "^1.7.2",
|
||||
"cookies-next": "^2.1.2",
|
||||
"axios": "^1.7.7",
|
||||
"cookies-next": "^4.2.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.15.5",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"markdown-to-jsx": "^7.4.7",
|
||||
"jose": "^5.9.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"markdown-to-jsx": "^7.5.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^14.2.3",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next": "^14.2.26",
|
||||
"next-http-proxy-middleware": "^1.2.6",
|
||||
"next-pwa": "^5.6.0",
|
||||
"p-limit": "^4.0.0",
|
||||
"p-limit": "^6.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-intl": "^6.6.8",
|
||||
"sharp": "^0.33.4",
|
||||
"sharp": "^0.33.5",
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/react": "18.3.2",
|
||||
"@types/node": "22.5.5",
|
||||
"@types/react": "18.3.7",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@typescript-eslint/parser": "^7.10.0",
|
||||
"axios": "^1.7.2",
|
||||
"@typescript-eslint/parser": "^8.6.0",
|
||||
"axios": "^1.7.7",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "^13.5.6",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tar": "^6.2.1",
|
||||
"typescript": "^5.4.5"
|
||||
"eslint-config-next": "^14.2.12",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tar": "^7.4.3",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const CreateEnableTotpModal = ({
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(options.secret);
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("common.notify.copied"));
|
||||
}}
|
||||
>
|
||||
{options.secret}
|
||||
|
||||
@@ -5,10 +5,9 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
const showReverseShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
reverseShareToken: string,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/upload/${reverseShareToken}`;
|
||||
const link = `${window.location.origin}/upload/${reverseShareToken}`;
|
||||
return modals.openModal({
|
||||
title: t("account.reverseShares.modal.reverse-share-link"),
|
||||
children: (
|
||||
|
||||
@@ -11,11 +11,10 @@ import CopyTextField from "../upload/CopyTextField";
|
||||
const showShareInformationsModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: MyShare,
|
||||
appUrl: string,
|
||||
maxShareSize: number,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${share.id}`;
|
||||
const link = `${window.location.origin}/s/${share.id}`;
|
||||
|
||||
const formattedShareSize = byteToHumanSizeString(share.size);
|
||||
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
|
||||
|
||||
@@ -2,13 +2,9 @@ import { Stack, TextInput } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
|
||||
const showShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
shareId: string,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${shareId}`;
|
||||
const link = `${window.location.origin}/s/${shareId}`;
|
||||
return modals.openModal({
|
||||
title: t("account.shares.modal.share-link"),
|
||||
children: (
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
|
||||
import { stringToTimespan, timespanToString } from "../../../utils/date.util";
|
||||
import FileSizeInput from "../../core/FileSizeInput";
|
||||
import TimespanInput from "../../core/TimespanInput";
|
||||
|
||||
const AdminConfigInput = ({
|
||||
configVariable,
|
||||
@@ -38,9 +41,11 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "string" &&
|
||||
(configVariable.obscured ? (
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("stringValue")}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
@@ -49,6 +54,7 @@ const AdminConfigInput = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("stringValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
@@ -60,6 +66,7 @@ const AdminConfigInput = ({
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
autosize
|
||||
{...form.getInputProps("textValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
@@ -69,18 +76,40 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
disabled={!configVariable.allowEdit}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(number) => onValueChange(configVariable, number)}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "filesize" && (
|
||||
<FileSizeInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
disabled={!configVariable.allowEdit}
|
||||
value={parseInt(configVariable.value ?? configVariable.defaultValue)}
|
||||
onChange={(bytes) => onValueChange(configVariable, bytes)}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "boolean" && (
|
||||
<>
|
||||
<Switch
|
||||
disabled={!configVariable.allowEdit}
|
||||
{...form.getInputProps("booleanValue", { type: "checkbox" })}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.checked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{configVariable.type == "timespan" && (
|
||||
<TimespanInput
|
||||
value={stringToTimespan(configVariable.value)}
|
||||
disabled={!configVariable.allowEdit}
|
||||
onChange={(timespan) =>
|
||||
onValueChange(configVariable, timespanToString(timespan))
|
||||
}
|
||||
w={201}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,15 +11,29 @@ import {
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb";
|
||||
import {
|
||||
TbAt,
|
||||
TbBinaryTree,
|
||||
TbBucket,
|
||||
TbMail,
|
||||
TbScale,
|
||||
TbServerBolt,
|
||||
TbSettings,
|
||||
TbShare,
|
||||
TbSocial,
|
||||
} from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const categories = [
|
||||
{ name: "General", icon: <TbSquare /> },
|
||||
{ name: "General", icon: <TbSettings /> },
|
||||
{ name: "Email", icon: <TbMail /> },
|
||||
{ name: "Share", icon: <TbShare /> },
|
||||
{ name: "SMTP", icon: <TbAt /> },
|
||||
{ name: "OAuth", icon: <TbSocial /> },
|
||||
{ name: "LDAP", icon: <TbBinaryTree /> },
|
||||
{ name: "S3", icon: <TbBucket /> },
|
||||
{ name: "Legal", icon: <TbScale /> },
|
||||
{ name: "Cache", icon: <TbServerBolt /> },
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user