Compare commits
425 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
205304b4f4 | ||
|
|
261d7742d9 | ||
|
|
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 | ||
|
|
601772d2f4 | ||
|
|
0e66be5f08 | ||
|
|
4cabcfb715 | ||
|
|
e5e9d85d39 | ||
|
|
70fd2d94be | ||
|
|
e5a0c649e3 | ||
|
|
414bcecbb5 | ||
|
|
968352cb6c | ||
|
|
355f860387 | ||
|
|
083d82c28b | ||
|
|
046c630abf | ||
|
|
d2bfb9a55f | ||
|
|
fccf57e9e4 | ||
|
|
e1a68f75f7 | ||
|
|
9d9cc7b4ab | ||
|
|
d1cde75a66 | ||
|
|
bbc81d8dd0 | ||
|
|
0cdc04bfb5 | ||
|
|
367f804a49 | ||
|
|
9193a79b9a | ||
|
|
31366d961f | ||
|
|
2dac38560b | ||
|
|
db2720ab7b | ||
|
|
6d6b9e81ff | ||
|
|
f9ddd7bacd | ||
|
|
3773432eb5 | ||
|
|
46783ce463 | ||
|
|
c0cc16fa43 | ||
|
|
4fd29037a0 | ||
|
|
1c7832ad1f | ||
|
|
962ec27df4 | ||
|
|
9268e35141 | ||
|
|
e8be0d60e6 | ||
|
|
0eabf78f13 | ||
|
|
4136bf5778 | ||
|
|
42b3604e2a | ||
|
|
84f4c39c1e | ||
|
|
bfef246d98 | ||
|
|
3b89fb950a | ||
|
|
7afda85f03 | ||
|
|
a3a7a5d9ab | ||
|
|
74cd520cb8 | ||
|
|
a511f24a6b | ||
|
|
b3862f3f3e | ||
|
|
d147614f76 | ||
|
|
c999df15e0 | ||
|
|
908d6e298f | ||
|
|
44c4a2e269 | ||
|
|
dc060f258b | ||
|
|
3b1c9f1efb | ||
|
|
a45184995f | ||
|
|
b717663b5c | ||
|
|
0e12ba87bc | ||
|
|
ec1feadee9 | ||
|
|
2e0d8d4fed | ||
|
|
b7f0f9d3ee | ||
|
|
c303454db3 | ||
|
|
3972589f76 | ||
|
|
3c5e0ad513 | ||
|
|
384fd19203 | ||
|
|
9d1a12b0d1 | ||
|
|
24e100bd7b | ||
|
|
1da4feeb89 | ||
|
|
c0a245e11b | ||
|
|
7a15fbb465 | ||
|
|
0bfbaea49a | ||
|
|
82871ce5dc | ||
|
|
593a65dac1 | ||
|
|
92ee1ab527 | ||
|
|
e71f6cd159 | ||
|
|
0b07bfbc14 | ||
|
|
63842cd0cc | ||
|
|
9f686c6ee3 | ||
|
|
c6d8188e4e | ||
|
|
6d87e20e29 | ||
|
|
b8efb9f54b | ||
|
|
013b9886af | ||
|
|
43bff91db2 | ||
|
|
1aa3d8e5e8 | ||
|
|
4dae7e250a | ||
|
|
7e91d83f9a | ||
|
|
e11dbfe893 | ||
|
|
ea83cf3876 | ||
|
|
5ca0bffc0a | ||
|
|
64515d77cf | ||
|
|
6058dca273 | ||
|
|
d01cba4a06 | ||
|
|
98aa9f97ea | ||
|
|
9c734ec439 | ||
|
|
e663da45b1 | ||
|
|
f52dffdaac | ||
|
|
e572506d4f | ||
|
|
416eba6ae6 | ||
|
|
3880854240 | ||
|
|
43d186a370 | ||
|
|
76df6f66d9 | ||
|
|
c189cd97a5 | ||
|
|
d83e28a1c3 | ||
|
|
3299f767d3 | ||
|
|
16a9724693 | ||
|
|
0ccb836444 | ||
|
|
067652aa80 | ||
|
|
1523d1b5b2 | ||
|
|
ea14e28dd8 | ||
|
|
d7750086b5 | ||
|
|
eb7216b4b1 | ||
|
|
1d62225019 | ||
|
|
bf5250c4a7 | ||
|
|
cdd0a864d1 | ||
|
|
692c1bef25 | ||
|
|
fe09d0e25f | ||
|
|
3ce18dc1dc | ||
|
|
6fb31abd84 | ||
|
|
7a301b455c | ||
|
|
5781a7b540 | ||
|
|
2efbeee5bf | ||
|
|
be4ff0f0f0 | ||
|
|
3ea52a24ef | ||
|
|
f179189b59 | ||
|
|
bc333f768f | ||
|
|
26c98e2b41 | ||
|
|
4b7732838d | ||
|
|
021b9ac5d5 | ||
|
|
5f94c7295a | ||
|
|
d9a9523c9a | ||
|
|
384d2343d5 | ||
|
|
7a387d86d6 | ||
|
|
330eef51e4 | ||
|
|
2e1a2b60c4 | ||
|
|
9896ca0e8c | ||
|
|
fd44f42f28 | ||
|
|
966ce261cb | ||
|
|
5503e7a54f | ||
|
|
b49ec93c54 | ||
|
|
e6584322fa | ||
|
|
1138cd02b0 | ||
|
|
1ba8d0cbd1 | ||
|
|
98380e2d48 | ||
|
|
e377ed10e1 | ||
|
|
acc35f4717 | ||
|
|
33742a043d | ||
|
|
5cee9cbbb9 | ||
|
|
e0fbbeca3c | ||
|
|
bbfc9d6f14 | ||
|
|
46b6e56c06 | ||
|
|
05f6582739 | ||
|
|
119b1ec840 | ||
|
|
e89e313712 | ||
|
|
c2ff658182 | ||
|
|
02cd98fa9c | ||
|
|
d327bc355c | ||
|
|
8ae631a626 | ||
|
|
1d8dc8fe5b | ||
|
|
688ae6c86e | ||
|
|
21809843cd | ||
|
|
b088a5ef2a | ||
|
|
c502cd58db | ||
|
|
97e7d7190d | ||
|
|
38919003e9 | ||
|
|
f15a8dc277 | ||
|
|
92927b1373 | ||
|
|
6a4108ed61 | ||
|
|
c9f1be2faf | ||
|
|
57be6945f2 | ||
|
|
82abe52ea5 | ||
|
|
6fa7af7905 | ||
|
|
13e7a30bb9 | ||
|
|
955af04e32 | ||
|
|
035e67f759 | ||
|
|
167ec782ef | ||
|
|
743c33475f | ||
|
|
3f1d3b7833 | ||
|
|
3d76e41cd8 | ||
|
|
e9efbc17bc | ||
|
|
307d176430 | ||
|
|
7e24ba9721 | ||
|
|
f9774d82d8 | ||
|
|
7647a9f620 | ||
|
|
d4e8d4f58b | ||
|
|
4df8dea5cc | ||
|
|
84aa100f84 | ||
|
|
bddb87b9b3 | ||
|
|
18c10c0ac6 | ||
|
|
f02e2979c4 | ||
|
|
7b34cb14cb | ||
|
|
019ef090ac | ||
|
|
7304b54125 | ||
|
|
ea0d5216e8 | ||
|
|
62deb6c152 | ||
|
|
9ba2b4c82c | ||
|
|
a47d080657 |
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: |
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord
|
||||
url: https://discord.gg/wHRQ9nFRcK
|
||||
about: For help and chatting with the community
|
||||
17
.github/ISSUE_TEMPLATE/question.yml
vendored
17
.github/ISSUE_TEMPLATE/question.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: ❓ Question
|
||||
description: "Submit a question"
|
||||
title: "❓ Question:"
|
||||
labels: [question]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "🙋♂️ Question"
|
||||
description: "A clear question. Please provide as much detail as possible."
|
||||
placeholder: "How do I ...?"
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, please check if the question hasn't been asked before.
|
||||
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
|
||||
|
||||
49
.github/workflows/build-docker-image.yml
vendored
49
.github/workflows/build-docker-image.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Create Docker Image
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -6,19 +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
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag stonith404/pingvin-share:latest \
|
||||
--tag stonith404/pingvin-share:${{ github.ref_name }} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
|
||||
- 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@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
23
.github/workflows/close_inactive_issues.yml
vendored
23
.github/workflows/close_inactive_issues.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "00 00 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
exempt-issue-labels: "feature"
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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/**/**
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/backend/src/constants.ts
|
||||
619
CHANGELOG.md
619
CHANGELOG.md
@@ -1,3 +1,622 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** Add role-based access management from OpenID Connect ([#535](https://github.com/stonith404/pingvin-share/issues/535)) ([70fd2d9](https://github.com/stonith404/pingvin-share/commit/70fd2d94be3411cc430f5c56e522028398127efb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* store only 10 share tokens in the cookies and clear the expired ones ([e5a0c64](https://github.com/stonith404/pingvin-share/commit/e5a0c649e36e0db419d04446affe2564c45cf321))
|
||||
|
||||
## [0.27.0](https://github.com/stonith404/pingvin-share/compare/v0.26.0...v0.27.0) (2024-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add logs for successful registration, successful login and failed login ([d2bfb9a](https://github.com/stonith404/pingvin-share/commit/d2bfb9a55fdad6a05377b8552471cf1151304c90))
|
||||
* **auth:** Allow to hide username / password login form when OAuth is enabled ([#518](https://github.com/stonith404/pingvin-share/issues/518)) ([e1a68f7](https://github.com/stonith404/pingvin-share/commit/e1a68f75f7b034f1ef9e45f26de584f13e355589)), closes [#489](https://github.com/stonith404/pingvin-share/issues/489)
|
||||
* **smtp:** allow unauthorized mail server certificates ([#525](https://github.com/stonith404/pingvin-share/issues/525)) ([083d82c](https://github.com/stonith404/pingvin-share/commit/083d82c28b835c178f076e89ef8f5885e8ea31cb))
|
||||
|
||||
## [0.26.0](https://github.com/stonith404/pingvin-share/compare/v0.25.0...v0.26.0) (2024-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backend:** Make session duration configurable ([#512](https://github.com/stonith404/pingvin-share/issues/512)) ([367f804](https://github.com/stonith404/pingvin-share/commit/367f804a494c85b4caf879d51982339fb6b86ba1)), closes [#507](https://github.com/stonith404/pingvin-share/issues/507)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oauth:** provider username is ignored when signing up using OAuth ([#511](https://github.com/stonith404/pingvin-share/issues/511)) ([31366d9](https://github.com/stonith404/pingvin-share/commit/31366d961f5827c200038b65ec9de5d4ddc8b898)), closes [#505](https://github.com/stonith404/pingvin-share/issues/505)
|
||||
* set max age of access token cookie to 15 minutes ([2dac385](https://github.com/stonith404/pingvin-share/commit/2dac38560b6c54b6e7676dcd4682bfa57973292f))
|
||||
|
||||
## [0.25.0](https://github.com/stonith404/pingvin-share/compare/v0.24.2...v0.25.0) (2024-06-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add auto open share modal config for global. ([#474](https://github.com/stonith404/pingvin-share/issues/474)) ([4fd2903](https://github.com/stonith404/pingvin-share/commit/4fd29037a08dbe505bdd8cf20f6f114cbade8483))
|
||||
* **frontend:** locale for dates and tooltip for copy link button ([#492](https://github.com/stonith404/pingvin-share/issues/492)) ([1c7832a](https://github.com/stonith404/pingvin-share/commit/1c7832ad1fb445fd1dbe1c111be5a331eaa4b797))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share size not displayed on my shares page ([c0cc16f](https://github.com/stonith404/pingvin-share/commit/c0cc16fa430bc64afb024c19d5faf24456bd417c))
|
||||
|
||||
## [0.24.2](https://github.com/stonith404/pingvin-share/compare/v0.24.1...v0.24.2) (2024-05-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin couldn't delete shares created by anonymous users ([7afda85](https://github.com/stonith404/pingvin-share/commit/7afda85f03d410a6c611860d0c3fb2b88a2e3679))
|
||||
* whitespace in title on homepage ([74cd520](https://github.com/stonith404/pingvin-share/commit/74cd520cb8c4ea87822ab6d54c0bf010455f401b))
|
||||
|
||||
## [0.24.1](https://github.com/stonith404/pingvin-share/compare/v0.24.0...v0.24.1) (2024-05-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error on admin share management page if a share was created by an anonymous user ([c999df1](https://github.com/stonith404/pingvin-share/commit/c999df15e04a927f6e952db3c807b9591fb14894))
|
||||
|
||||
## [0.24.0](https://github.com/stonith404/pingvin-share/compare/v0.23.1...v0.24.0) (2024-05-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin-exclusive share-management page ([#461](https://github.com/stonith404/pingvin-share/issues/461)) ([3b1c9f1](https://github.com/stonith404/pingvin-share/commit/3b1c9f1efb7d02469e92537a2d1378b6cb412878))
|
||||
* add name property to share ([#462](https://github.com/stonith404/pingvin-share/issues/462)) ([b717663](https://github.com/stonith404/pingvin-share/commit/b717663b5c3a4a98e361e7e39b680f4852537c59))
|
||||
|
||||
## [0.23.1](https://github.com/stonith404/pingvin-share/compare/v0.23.0...v0.23.1) (2024-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** crash on unhandled promise rejections ([1da4fee](https://github.com/stonith404/pingvin-share/commit/1da4feeb895a13d0a0ae754bd716a84e8186d081))
|
||||
* changing the chunk size needed an app restart ([24e100b](https://github.com/stonith404/pingvin-share/commit/24e100bd7be8bf20778bdf2767aa35cae8d7e502))
|
||||
* disable js execution on raw file view ([9d1a12b](https://github.com/stonith404/pingvin-share/commit/9d1a12b0d1812214f1fe6fa56e3848091ce4945c))
|
||||
* incorrect layout on 404 page ([3c5e0ad](https://github.com/stonith404/pingvin-share/commit/3c5e0ad5134ee2d405ac420152b5825102f65bfc))
|
||||
* normal shares were added to the previous reverse share ([3972589](https://github.com/stonith404/pingvin-share/commit/3972589f76519b03074d916fb2460c795b1f0737))
|
||||
* redirect vulnerability on error, sign in and totp page ([384fd19](https://github.com/stonith404/pingvin-share/commit/384fd19203b63eeb4b952f83a9e1eaab1b19b90d))
|
||||
|
||||
## [0.23.0](https://github.com/stonith404/pingvin-share/compare/v0.22.2...v0.23.0) (2024-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add config variable to adjust chunk size ([0bfbaea](https://github.com/stonith404/pingvin-share/commit/0bfbaea49aad0c695fee6558c89c661687912e4f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete share files if user gets deleted ([e71f6cd](https://github.com/stonith404/pingvin-share/commit/e71f6cd1598ed87366074398042a6b88675587ca))
|
||||
* error in logs if "allow unauthenticated shares" is enabled ([c6d8188](https://github.com/stonith404/pingvin-share/commit/c6d8188e4e33ba682551a3ca79205ff5a6d7ead5))
|
||||
* memory leak while uploading files by disabling base64 encoding of chunks ([7a15fbb](https://github.com/stonith404/pingvin-share/commit/7a15fbb4651c2fee32fb4c1ee2c9d7f12323feb0))
|
||||
|
||||
## [0.22.2](https://github.com/stonith404/pingvin-share/compare/v0.22.1...v0.22.2) (2024-02-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* extend access token cookie expiration ([013b988](https://github.com/stonith404/pingvin-share/commit/013b9886af5629b2ead6000b962267afc761c612))
|
||||
* reduce refresh access token calls ([1aa3d8e](https://github.com/stonith404/pingvin-share/commit/1aa3d8e5e89b3696cc9554f41e9ce13806dde406))
|
||||
* replace Nginx with Caddy to fix "premature close" error while downloading larger files ([43bff91](https://github.com/stonith404/pingvin-share/commit/43bff91db2ba4ec68d76e601f7bc42cb7a506bc5))
|
||||
|
||||
## [0.22.1](https://github.com/stonith404/pingvin-share/compare/v0.22.0...v0.22.1) (2024-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* back links on error modals ([f52dffd](https://github.com/stonith404/pingvin-share/commit/f52dffdaac5a893804525913943f3f4f99b7c55a))
|
||||
* prevent zoom on input field click on mobile ([9c734ec](https://github.com/stonith404/pingvin-share/commit/9c734ec439aeaeebe172caa41bf531e6d8b3fac3))
|
||||
* replace middleware backend url with local backend url ([76df6f6](https://github.com/stonith404/pingvin-share/commit/76df6f66d965dd751146468abfafb0c6acd46310))
|
||||
* user `id` and `totpVerified` can't be changed by user ([e663da4](https://github.com/stonith404/pingvin-share/commit/e663da45b1d15f5e6e33118e6a28e1504688034c))
|
||||
* user enumaration on forgot password page ([64515d7](https://github.com/stonith404/pingvin-share/commit/64515d77cfc116a243d78610395ccc383ba62940))
|
||||
|
||||
## [0.22.0](https://github.com/stonith404/pingvin-share/compare/v0.21.5...v0.22.0) (2024-02-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **translations:** typo in string ([c189cd9](https://github.com/stonith404/pingvin-share/commit/c189cd97a502cee8ea79e5187d9288d636d4983c))
|
||||
|
||||
## [0.21.5](https://github.com/stonith404/pingvin-share/compare/v0.21.4...v0.21.5) (2024-01-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* password can be changed with wrong password ([0ccb836](https://github.com/stonith404/pingvin-share/commit/0ccb8364448d27ea07c8b11972ff454d610893c6))
|
||||
|
||||
## [0.21.4](https://github.com/stonith404/pingvin-share/compare/v0.21.3...v0.21.4) (2024-01-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add navigateToLink button for CopyTextField. close [#372](https://github.com/stonith404/pingvin-share/issues/372). ([#376](https://github.com/stonith404/pingvin-share/issues/376)) ([d775008](https://github.com/stonith404/pingvin-share/commit/d7750086b5b796cfc70d8dc0c7d0ab4bd1996ca0))
|
||||
|
||||
## [0.21.3](https://github.com/stonith404/pingvin-share/compare/v0.21.2...v0.21.3) (2024-01-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't show validation error on upload modal if password or max views are empty ([fe09d0e](https://github.com/stonith404/pingvin-share/commit/fe09d0e25f6fbfc4e1c9302054d3387fe8b1f0ea))
|
||||
|
||||
## [0.21.2](https://github.com/stonith404/pingvin-share/compare/v0.21.1...v0.21.2) (2023-12-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing logo images on fresh installation ([6fb31ab](https://github.com/stonith404/pingvin-share/commit/6fb31abd84b22cd464b6b45bf7ca6f83853e8720))
|
||||
* missing translations on reset password page ([7a301b4](https://github.com/stonith404/pingvin-share/commit/7a301b455cdea4b1dbc04cc6223e094fee9aca7b))
|
||||
|
||||
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
|
||||
|
||||
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
|
||||
|
||||
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
|
||||
|
||||
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
|
||||
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
|
||||
|
||||
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
|
||||
|
||||
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
|
||||
|
||||
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
|
||||
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
|
||||
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
|
||||
|
||||
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
|
||||
|
||||
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
|
||||
|
||||
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
|
||||
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
|
||||
|
||||
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
|
||||
|
||||
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
|
||||
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
|
||||
|
||||
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
|
||||
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
|
||||
|
||||
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
|
||||
|
||||
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
|
||||
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
|
||||
|
||||
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
|
||||
|
||||
|
||||
|
||||
48
Dockerfile
48
Dockerfile
@@ -1,37 +1,43 @@
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:19-slim AS frontend-dependencies
|
||||
# Stage 1: 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: on frontend change
|
||||
FROM node:19-slim AS frontend-builder
|
||||
# Stage 2: Build frontend
|
||||
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: on backend dependency change
|
||||
FROM node:19-slim AS backend-dependencies
|
||||
# Stage 3: 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:on backend change
|
||||
FROM node:19-slim AS backend-builder
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
# Stage 4: Build backend
|
||||
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
|
||||
RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:19-slim AS runner
|
||||
FROM node:22-alpine AS runner
|
||||
ENV NODE_ENV=docker
|
||||
RUN apt-get update && apt-get install -y curl openssl
|
||||
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk update --no-cache \
|
||||
&& apk upgrade --no-cache \
|
||||
&& apk add --no-cache curl caddy su-exec openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /opt/app/public ./public
|
||||
@@ -44,10 +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
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
# HOSTNAME=127.0.0.1 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed
|
||||
CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=127.0.0.1 node frontend/server.js & cd backend && npm run prod
|
||||
COPY ./reverse-proxy /opt/app/reverse-proxy
|
||||
COPY ./scripts/docker ./scripts/docker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
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'
|
||||
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
134
README.md
134
README.md
@@ -1,12 +1,10 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
||||
|
||||
---
|
||||
|
||||
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 +13,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,133 +27,26 @@ 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
|
||||
2. Run `docker-compose up -d`
|
||||
2. Run `docker compose up -d`
|
||||
|
||||
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
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Integrations
|
||||
|
||||
#### ClamAV (Docker only)
|
||||
|
||||
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||
|
||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||
|
||||
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Stand-alone
|
||||
|
||||
1. Stop the running app
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
##### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
|
||||
##### Frontend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
### Translations
|
||||
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).
|
||||
|
||||
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.
|
||||
## ❤️ Sponsors
|
||||
|
||||
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).
|
||||
Thank you for supporting Pingvin Share 🙏
|
||||
|
||||
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.
|
||||
- [@COMPLEXWASTAKEN](https://github.com/COMPLEXWASTAKEN)
|
||||
|
||||
@@ -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).
|
||||
|
||||
1
backend/.prettierignore
Normal file
1
backend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/src/constants.ts
|
||||
15683
backend/package-lock.json
generated
15683
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.17.2",
|
||||
"version": "1.12.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
@@ -13,68 +13,78 @@
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.1.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.2",
|
||||
"@nestjs/jwt": "^10.1.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.1.2",
|
||||
"@nestjs/schedule": "^3.0.1",
|
||||
"@nestjs/swagger": "^7.1.4",
|
||||
"@nestjs/throttler": "^4.2.1",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"clamscan": "^2.1.2",
|
||||
"@aws-sdk/client-s3": "^3.787.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.41.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"cache-manager": "^6.4.2",
|
||||
"clamscan": "^2.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nodemailer": "^6.9.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"jmespath": "^0.16.0",
|
||||
"ldapts": "^7.4.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.10.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.32.4",
|
||||
"ts-node": "^10.9.1"
|
||||
"reflect-metadata": "^0.2.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.1.10",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.1.2",
|
||||
"@types/archiver": "^5.3.2",
|
||||
"@types/clamscan": "^2.0.4",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-jwt": "^3.0.9",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"@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.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.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.46.0",
|
||||
"eslint-config-prettier": "^8.9.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"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.4.4",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.1.6",
|
||||
"wait-on": "^7.0.1"
|
||||
"typescript": "^5.8.3",
|
||||
"wait-on": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthUser" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerUserId" TEXT NOT NULL,
|
||||
"providerUsername" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpSecret" TEXT
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Share" ADD COLUMN "name" TEXT;
|
||||
@@ -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";
|
||||
@@ -14,8 +14,9 @@ model User {
|
||||
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
password String?
|
||||
isAdmin Boolean @default(false)
|
||||
ldapDN String? @unique
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
@@ -26,6 +27,8 @@ model User {
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
|
||||
oAuthUsers OAuthUser[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -37,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 {
|
||||
@@ -60,10 +65,20 @@ model ResetPasswordToken {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OAuthUser {
|
||||
id String @id @default(uuid())
|
||||
provider String
|
||||
providerUserId String
|
||||
providerUsername String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
name String?
|
||||
uploadLocked Boolean @default(false)
|
||||
isZipReady Boolean @default(false)
|
||||
views Int @default(0)
|
||||
@@ -80,6 +95,7 @@ model Share {
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
storageProvider String @default("LOCAL")
|
||||
}
|
||||
|
||||
model ReverseShare {
|
||||
@@ -91,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)
|
||||
@@ -134,7 +152,7 @@ model Config {
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables: ConfigVariables = {
|
||||
export const configVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
type: "string",
|
||||
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
@@ -20,11 +20,20 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
secureCookies: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
showHomePage: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
sessionDuration: {
|
||||
type: "timespan",
|
||||
defaultValue: "3 months",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
share: {
|
||||
allowRegistration: {
|
||||
@@ -37,8 +46,18 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
maxExpiration: {
|
||||
type: "timespan",
|
||||
defaultValue: "0 days",
|
||||
secret: false,
|
||||
},
|
||||
shareIdLength: {
|
||||
type: "number",
|
||||
defaultValue: "8",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
type: "filesize",
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
@@ -46,12 +65,21 @@ const configVariables: ConfigVariables = {
|
||||
type: "number",
|
||||
defaultValue: "9",
|
||||
},
|
||||
chunkSize: {
|
||||
type: "filesize",
|
||||
defaultValue: "10000000",
|
||||
secret: false,
|
||||
},
|
||||
autoOpenShareModal: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
shareRecipientsSubject: {
|
||||
@@ -61,7 +89,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",
|
||||
@@ -79,7 +107,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",
|
||||
@@ -88,7 +116,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: {
|
||||
@@ -97,6 +125,12 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthorizedCertificates: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
host: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
@@ -119,6 +153,254 @@ 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: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
ignoreTotp: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
disablePassword: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
"github-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"github-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"github-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"google-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"google-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"google-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"microsoft-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"microsoft-tenant": {
|
||||
type: "string",
|
||||
defaultValue: "common",
|
||||
},
|
||||
"microsoft-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"microsoft-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"discord-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"discord-limitedGuild": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-limitedUsers": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"oidc-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-discoveryUri": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-signOut": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-scope": {
|
||||
type: "string",
|
||||
defaultValue: "openid email profile",
|
||||
},
|
||||
"oidc-usernameClaim": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-rolePath": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-roleGeneralAccess": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-roleAdminAccess": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
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,
|
||||
},
|
||||
companySharingPolicy: {
|
||||
type: "text",
|
||||
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 = {
|
||||
@@ -170,12 +452,15 @@ async function seedConfigVariables() {
|
||||
|
||||
async function migrateConfigVariables() {
|
||||
const existingConfigVariables = await prisma.config.findMany();
|
||||
const orderMap: { [category: string]: number } = {};
|
||||
|
||||
for (const existingConfigVariable of existingConfigVariables) {
|
||||
const configVariable =
|
||||
configVariables[existingConfigVariable.category]?.[
|
||||
existingConfigVariable.name
|
||||
];
|
||||
|
||||
// Delete the config variable if it doesn't exist in the seed
|
||||
if (!configVariable) {
|
||||
await prisma.config.delete({
|
||||
where: {
|
||||
@@ -186,15 +471,11 @@ async function migrateConfigVariables() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update the config variable if the metadata changed
|
||||
} else if (
|
||||
JSON.stringify({
|
||||
...configVariable,
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
}) != JSON.stringify(existingConfigVariable)
|
||||
) {
|
||||
// Update the config variable if it exists in the seed
|
||||
} else {
|
||||
const variableOrder = Object.keys(
|
||||
configVariables[existingConfigVariable.category]
|
||||
).indexOf(existingConfigVariable.name);
|
||||
await prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -207,8 +488,10 @@ async function migrateConfigVariables() {
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
order: variableOrder,
|
||||
},
|
||||
});
|
||||
orderMap[existingConfigVariable.category] = variableOrder + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
backend/src/app.controller.ts
Normal file
19
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
|
||||
@Controller("/")
|
||||
export class AppController {
|
||||
constructor(private prismaService: PrismaService) {}
|
||||
|
||||
@Get("health")
|
||||
async health(@Res({ passthrough: true }) res: Response) {
|
||||
try {
|
||||
await this.prismaService.config.findMany();
|
||||
return "OK";
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
return "ERROR";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,46 @@ 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 { AppController } from "./app.controller";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { FileModule } from "./file/file.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { OAuthModule } from "./oauth/oauth.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@@ -33,43 +33,55 @@ export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private authTotpService: AuthTotpService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post("signUp")
|
||||
@Throttle(10, 5 * 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Req() { ip }: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!this.config.get("share.allowRegistration"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
|
||||
const result = await this.authService.signUp(dto);
|
||||
const result = await this.authService.signUp(dto, ip);
|
||||
|
||||
response = this.addTokensToResponse(
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("signIn")
|
||||
@Throttle(10, 5 * 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Req() { ip }: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authService.signIn(dto);
|
||||
const result = await this.authService.signIn(dto, ip);
|
||||
|
||||
if (result.accessToken && result.refreshToken) {
|
||||
response = this.addTokensToResponse(
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,32 +89,47 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post("signIn/totp")
|
||||
@Throttle(10, 5 * 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authTotpService.signInTotp(dto);
|
||||
|
||||
response = this.addTokensToResponse(
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(202)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
return await this.authService.requestResetPassword(email);
|
||||
await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle(5, 5 * 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
@@ -113,15 +140,15 @@ export class AuthController {
|
||||
async updatePassword(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() dto: UpdatePasswordDTO
|
||||
@Body() dto: UpdatePasswordDTO,
|
||||
) {
|
||||
const result = await this.authService.updatePassword(
|
||||
user,
|
||||
dto.password,
|
||||
dto.oldPassword,
|
||||
dto.password
|
||||
);
|
||||
|
||||
response = this.addTokensToResponse(response, result.refreshToken);
|
||||
this.authService.addTokensToResponse(response, result.refreshToken);
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@@ -129,29 +156,41 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||
|
||||
const accessToken = await this.authService.refreshAccessToken(
|
||||
request.cookies.refresh_token
|
||||
request.cookies.refresh_token,
|
||||
);
|
||||
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||
this.authService.addTokensToResponse(response, undefined, accessToken);
|
||||
return new TokenDTO().from({ accessToken });
|
||||
}
|
||||
|
||||
@Post("signOut")
|
||||
async signOut(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@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")
|
||||
@@ -172,22 +211,4 @@ export class AuthController {
|
||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||
}
|
||||
|
||||
private addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||
if (refreshToken)
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
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: [JwtModule.register({}), EmailModule],
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
forwardRef(() => OAuthModule),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import { Request, Response } from "express";
|
||||
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 {
|
||||
@@ -21,64 +29,106 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||
) {}
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||
|
||||
const hash = await argon.hash(dto.password);
|
||||
const hash = dto.password ? await argon.hash(dto.password) : null;
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: isFirstUser,
|
||||
isAdmin: isAdmin ?? isFirstUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
this.logger.log(`User ${user.email} signed up from IP ${ip}`);
|
||||
return { accessToken, refreshToken, 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`
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO) {
|
||||
if (!dto.email && !dto.username)
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username) {
|
||||
throw new BadRequestException("Email or username is required");
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
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, 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) {
|
||||
if (user.totpVerified && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
user.id,
|
||||
oauth?.idToken,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -86,12 +136,24 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async requestResetPassword(email: string) {
|
||||
if (this.config.get("oauth.disablePassword"))
|
||||
throw new ForbiddenException("Password sign in is disabled");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
include: { resetPasswordToken: true },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
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) {
|
||||
@@ -107,10 +169,13 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
if (this.config.get("oauth.disablePassword"))
|
||||
throw new ForbiddenException("Password sign in is disabled");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { resetPasswordToken: { token } },
|
||||
});
|
||||
@@ -129,9 +194,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
|
||||
const isPasswordValid =
|
||||
!user.password || (await argon.verify(user.password, oldPassword));
|
||||
|
||||
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
|
||||
|
||||
const hash = await argon.hash(newPassword);
|
||||
|
||||
@@ -158,7 +225,7 @@ export class AuthService {
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,13 +298,20 @@ export class AuthService {
|
||||
|
||||
return this.createAccessToken(
|
||||
refreshTokenMetaData.user,
|
||||
refreshTokenMetaData.id
|
||||
refreshTokenMetaData.id,
|
||||
);
|
||||
}
|
||||
|
||||
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(3, "months").toDate() },
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: moment()
|
||||
.add(sessionDuration.value, sessionDuration.unit)
|
||||
.toDate(),
|
||||
oauthIDToken: idToken,
|
||||
},
|
||||
});
|
||||
|
||||
return { refreshTokenId: id, refreshToken: token };
|
||||
@@ -210,4 +326,58 @@ export class AuthService {
|
||||
|
||||
return loginToken;
|
||||
}
|
||||
|
||||
addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string,
|
||||
) {
|
||||
const isSecure = this.config.get("general.secureCookies");
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, {
|
||||
sameSite: "lax",
|
||||
secure: isSecure,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
|
||||
});
|
||||
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",
|
||||
secure: isSecure,
|
||||
maxAge,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user id if the user is logged in, null otherwise
|
||||
*/
|
||||
async getIdOfCurrentUser(request: Request): Promise<string | null> {
|
||||
if (!request.cookies.access_token) return null;
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(
|
||||
request.cookies.access_token,
|
||||
{
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
},
|
||||
);
|
||||
return payload.sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +5,6 @@ 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";
|
||||
@@ -17,48 +16,34 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
if (!dto.email && !dto.username)
|
||||
throw new BadRequestException("Email or username is required");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
const token = await this.prisma.loginToken.findFirst({
|
||||
where: {
|
||||
token: dto.loginToken,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!token || token.userId != user.id || token.used)
|
||||
if (!token || token.used)
|
||||
throw new UnauthorizedException("Invalid login token");
|
||||
|
||||
if (token.expiresAt < new Date())
|
||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||
|
||||
// Check the TOTP code
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpSecret: true },
|
||||
});
|
||||
const { totpSecret } = token.user;
|
||||
|
||||
if (!totpSecret) {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (dto.totp !== expected) {
|
||||
if (!authenticator.check(dto.totp, totpSecret)) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
}
|
||||
|
||||
@@ -69,17 +54,17 @@ export class AuthTotpService {
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } =
|
||||
await this.authService.createRefreshToken(user.id);
|
||||
await this.authService.createRefreshToken(token.user.id);
|
||||
const accessToken = await this.authService.createAccessToken(
|
||||
user,
|
||||
refreshTokenId
|
||||
token.user,
|
||||
refreshTokenId,
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
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
|
||||
@@ -92,14 +77,10 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const issuer = this.configService.get("general.appName");
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
this.config.get("general.appName"),
|
||||
secret
|
||||
);
|
||||
const otpURL = totp.keyuri(user.username || user.email, issuer, secret);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -124,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({
|
||||
@@ -155,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({
|
||||
|
||||
@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { IsString } from "class-validator";
|
||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||
|
||||
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||
export class AuthSignInTotpDTO {
|
||||
@IsString()
|
||||
totp: 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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
oldPassword: string;
|
||||
@IsOptional()
|
||||
oldPassword?: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
config.get("internal.jwtSecret");
|
||||
super({
|
||||
jwtFromRequest: JwtStrategy.extractJWT,
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ClamScanService {
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
@@ -81,7 +81,7 @@ export class ClamScanService {
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ConfigController {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private logoService: LogoService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -41,7 +41,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getByCategory(@Param("category") category: string) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.getByCategory(category)
|
||||
await this.configService.getByCategory(category),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.updateMany(data)
|
||||
await this.configService.updateMany(data),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export class ConfigController {
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||
})
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
return await this.logoService.create(file.buffer);
|
||||
}
|
||||
|
||||
@@ -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,52 +2,129 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
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";
|
||||
|
||||
/**
|
||||
* ConfigService extends EventEmitter to allow listening for config updates,
|
||||
* now only `update` event will be emitted.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
export class ConfigService extends EventEmitter {
|
||||
yamlConfig?: YamlConfig;
|
||||
logger = new Logger(ConfigService.name);
|
||||
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
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.yaml", "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
|
||||
(variable) => `${variable.category}.${variable.name}` == key,
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "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 {
|
||||
@@ -59,6 +136,11 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -69,6 +151,11 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -86,13 +173,16 @@ export class ConfigService {
|
||||
} 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}`
|
||||
`Config variable must be of type ${configVariable.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.validateConfigVariable(key, value);
|
||||
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
@@ -105,6 +195,33 @@ export class ConfigService {
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
this.emit("update", key, value);
|
||||
|
||||
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,
|
||||
@@ -25,7 +28,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ConfigDTO {
|
||||
|
||||
fromList(partial: Partial<ConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ const IMAGES_PATH = "../frontend/public/img";
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
async create(file: Buffer) {
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||
const resized = await sharp(file).resize(900).toBuffer();
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
||||
this.createFavicon(file);
|
||||
this.createPWAIcons(file);
|
||||
}
|
||||
@@ -25,7 +26,7 @@ export class LogoService {
|
||||
fs.promises.writeFile(
|
||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||
resized,
|
||||
"binary"
|
||||
"binary",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { LogLevel } from "@nestjs/common";
|
||||
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||
export const DATABASE_URL =
|
||||
@@ -7,3 +9,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,13 +17,19 @@ 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",
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,7 +38,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
"smtp.email",
|
||||
)}>`,
|
||||
to: email,
|
||||
subject,
|
||||
@@ -49,7 +55,7 @@ export class EmailService {
|
||||
shareId: string,
|
||||
creator?: User,
|
||||
description?: string,
|
||||
expiration?: Date
|
||||
expiration?: Date,
|
||||
) {
|
||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
@@ -63,14 +69,15 @@ 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(
|
||||
"{expires}",
|
||||
moment(expiration).unix() != 0
|
||||
? moment(expiration).fromNow()
|
||||
: "in: never"
|
||||
)
|
||||
: "in: never",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,13 +90,13 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"general.appUrl"
|
||||
"general.appUrl",
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.sendMail(
|
||||
@@ -98,7 +105,7 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl)
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +119,7 @@ export class EmailService {
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password)
|
||||
.replaceAll("{email}", recipientEmail),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +127,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
"smtp.email",
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
@@ -16,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 {
|
||||
@@ -25,21 +27,24 @@ export class FileController {
|
||||
@SkipThrottle()
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async create(
|
||||
@Query() query: any,
|
||||
|
||||
@Query()
|
||||
query: {
|
||||
id: string;
|
||||
name: string;
|
||||
chunkIndex: string;
|
||||
totalChunks: string;
|
||||
},
|
||||
@Body() body: string,
|
||||
@Param("shareId") shareId: string
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
// Data can be empty if the file is empty
|
||||
const data = body.toString().split(",")[1] ?? "";
|
||||
|
||||
return await this.fileService.create(
|
||||
data,
|
||||
body,
|
||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||
{ id, name },
|
||||
shareId
|
||||
shareId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,15 +52,16 @@ export class FileController {
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string
|
||||
@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")
|
||||
@@ -64,21 +70,37 @@ export class FileController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string,
|
||||
@Query("download") download = "true"
|
||||
@Query("download") download = "true",
|
||||
) {
|
||||
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": "sandbox",
|
||||
};
|
||||
|
||||
if (download === "true") {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||
} else {
|
||||
headers["Content-Disposition"] = contentDisposition(file.metaData.name, {
|
||||
type: "inline",
|
||||
});
|
||||
}
|
||||
|
||||
res.set(headers);
|
||||
|
||||
return new StreamableFile(file.file);
|
||||
}
|
||||
|
||||
@Delete(":fileId")
|
||||
@SkipThrottle()
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async remove(
|
||||
@Param("fileId") fileId: string,
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
await this.fileService.remove(shareId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,137 +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 },
|
||||
shareId: 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 = 10 * 1024 * 1024; // 10MB
|
||||
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||
|
||||
if (expectedChunkIndex != chunk.index)
|
||||
throw new BadRequestException({
|
||||
message: "Unexpected chunk received",
|
||||
error: "unexpected_chunk_index",
|
||||
expectedChunkIndex,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0
|
||||
);
|
||||
|
||||
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 },
|
||||
});
|
||||
const storageService = this.getStorageService(share.storageProvider);
|
||||
return storageService.get(shareId, fileId);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
async remove(shareId: string, fileId: string) {
|
||||
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
|
||||
private _prisma: PrismaService,
|
||||
_config: ConfigService,
|
||||
) {
|
||||
super(_shareService, _prisma);
|
||||
super(_shareService, _prisma, _config);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
@@ -24,7 +26,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -52,7 +54,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
"share_max_views_exceeded",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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}/` : "";
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export class JobsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -56,11 +56,33 @@ export class JobsService {
|
||||
|
||||
if (expiredReverseShares.length > 0) {
|
||||
this.logger.log(
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 */6 * * *")
|
||||
async deleteUnfinishedShares() {
|
||||
const unfinishedShares = await this.prisma.share.findMany({
|
||||
where: {
|
||||
createdAt: { lt: moment().subtract(1, "day").toDate() },
|
||||
uploadLocked: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const unfinishedShare of unfinishedShares) {
|
||||
await this.prisma.share.delete({
|
||||
where: { id: unfinishedShare.id },
|
||||
});
|
||||
|
||||
await this.fileService.deleteAllFiles(unfinishedShare.id);
|
||||
}
|
||||
|
||||
if (unfinishedShares.length > 0) {
|
||||
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
let filesDeleted = 0;
|
||||
@@ -77,7 +99,7 @@ export class JobsService {
|
||||
|
||||
for (const file of temporaryFiles) {
|
||||
const stats = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||
);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
@@ -93,7 +115,7 @@ export class JobsService {
|
||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@Cron("1 * * * *")
|
||||
async deleteExpiredTokens() {
|
||||
const { count: refreshTokenCount } =
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
|
||||
@@ -1,19 +1,60 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||
import {
|
||||
ClassSerializerInterceptor,
|
||||
Logger,
|
||||
LogLevel,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { NestFactory, Reflector } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import * as bodyParser from "body-parser";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
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)));
|
||||
|
||||
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||
const config = app.get<ConfigService>(ConfigService);
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const chunkSize = config.get("share.chunkSize");
|
||||
bodyParser.raw({
|
||||
type: "application/octet-stream",
|
||||
limit: `${chunkSize}B`,
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
app.use(cookieParser());
|
||||
app.set("trust proxy", true);
|
||||
|
||||
@@ -33,6 +74,11 @@ 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));
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
state: string;
|
||||
}
|
||||
8
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
8
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface OAuthSignInDto {
|
||||
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
idToken?: string;
|
||||
}
|
||||
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export class ErrorPageException extends Error {
|
||||
/**
|
||||
* Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix)
|
||||
* @param key i18n key of message
|
||||
* @param redirect redirect url
|
||||
* @param params message params (key)
|
||||
*/
|
||||
constructor(
|
||||
public readonly key: string = "default",
|
||||
public readonly redirect?: string,
|
||||
public readonly params?: string[],
|
||||
) {
|
||||
super("error");
|
||||
}
|
||||
}
|
||||
39
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
39
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
|
||||
@Catch(ErrorPageException)
|
||||
export class ErrorPageExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(ErrorPageExceptionFilter.name);
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
catch(exception: ErrorPageException, host: ArgumentsHost) {
|
||||
this.logger.error(
|
||||
JSON.stringify({
|
||||
error: exception.key,
|
||||
params: exception.params,
|
||||
redirect: exception.redirect,
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
|
||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
||||
url.searchParams.set("error", exception.key);
|
||||
if (exception.redirect) {
|
||||
url.searchParams.set("redirect", exception.redirect);
|
||||
} else {
|
||||
const redirect = ctx.getRequest().cookies.access_token
|
||||
? "/account"
|
||||
: "/auth/signIn";
|
||||
url.searchParams.set("redirect", redirect);
|
||||
}
|
||||
if (exception.params) {
|
||||
url.searchParams.set("params", exception.params.join(","));
|
||||
}
|
||||
|
||||
response.redirect(url.toString());
|
||||
}
|
||||
}
|
||||
38
backend/src/oauth/filter/oauthException.filter.ts
Normal file
38
backend/src/oauth/filter/oauthException.filter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
|
||||
@Catch(HttpException)
|
||||
export class OAuthExceptionFilter implements ExceptionFilter {
|
||||
private errorKeys: Record<string, string> = {
|
||||
access_denied: "access_denied",
|
||||
expired_token: "expired_token",
|
||||
};
|
||||
private readonly logger = new Logger(OAuthExceptionFilter.name);
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
this.logger.error(exception.message);
|
||||
this.logger.error(
|
||||
"Request query: " + JSON.stringify(request.query, null, 2),
|
||||
);
|
||||
|
||||
const key = this.errorKeys[request.query.error] || "default";
|
||||
|
||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
||||
url.searchParams.set("redirect", "/account");
|
||||
url.searchParams.set("error", key);
|
||||
|
||||
response.redirect(url.toString());
|
||||
}
|
||||
}
|
||||
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthGuard implements CanActivate {
|
||||
constructor() {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const provider = request.params.provider;
|
||||
return request.query.state === request.cookies[`oauth_${provider}_state`];
|
||||
}
|
||||
}
|
||||
24
backend/src/oauth/guard/provider.guard.ts
Normal file
24
backend/src/oauth/guard/provider.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class ProviderGuard implements CanActivate {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const provider = request.params.provider;
|
||||
return (
|
||||
this.platforms.includes(provider) &&
|
||||
this.config.get(`oauth.${provider}-enabled`)
|
||||
);
|
||||
}
|
||||
}
|
||||
110
backend/src/oauth/oauth.controller.ts
Normal file
110
backend/src/oauth/oauth.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { GetUser } from "../auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "../auth/guard/jwt.guard";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { OAuthCallbackDto } from "./dto/oauthCallback.dto";
|
||||
import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter";
|
||||
import { OAuthGuard } from "./guard/oauth.guard";
|
||||
import { ProviderGuard } from "./guard/provider.guard";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
import { OAuthExceptionFilter } from "./filter/oauthException.filter";
|
||||
|
||||
@Controller("oauth")
|
||||
export class OAuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private oauthService: OAuthService,
|
||||
private config: ConfigService,
|
||||
@Inject("OAUTH_PROVIDERS")
|
||||
private providers: Record<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
|
||||
@Get("available")
|
||||
available() {
|
||||
return this.oauthService.available();
|
||||
}
|
||||
|
||||
@Get("status")
|
||||
@UseGuards(JwtGuard)
|
||||
async status(@GetUser() user: User) {
|
||||
return this.oauthService.status(user);
|
||||
}
|
||||
|
||||
@Get("auth/:provider")
|
||||
@UseGuards(ProviderGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter)
|
||||
async auth(
|
||||
@Param("provider") provider: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const state = nanoid(16);
|
||||
const url = await this.providers[provider].getAuthEndpoint(state);
|
||||
response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" });
|
||||
response.redirect(url);
|
||||
}
|
||||
|
||||
@Get("callback/:provider")
|
||||
@UseGuards(ProviderGuard, OAuthGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter)
|
||||
async callback(
|
||||
@Param("provider") provider: string,
|
||||
@Query() query: OAuthCallbackDto,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const oauthToken = await this.providers[provider].getToken(query);
|
||||
const user = await this.providers[provider].getUserInfo(oauthToken, query);
|
||||
const id = await this.authService.getIdOfCurrentUser(request);
|
||||
|
||||
if (id) {
|
||||
await this.oauthService.link(
|
||||
id,
|
||||
provider,
|
||||
user.providerId,
|
||||
user.providerUsername,
|
||||
);
|
||||
response.redirect(this.config.get("general.appUrl") + "/account");
|
||||
} else {
|
||||
const token: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
loginToken?: string;
|
||||
} = await this.oauthService.signIn(user, request.ip);
|
||||
if (token.accessToken) {
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
token.refreshToken,
|
||||
token.accessToken,
|
||||
);
|
||||
response.redirect(this.config.get("general.appUrl"));
|
||||
} else {
|
||||
response.redirect(
|
||||
this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post("unlink/:provider")
|
||||
@UseGuards(JwtGuard, ProviderGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter)
|
||||
unlink(@GetUser() user: User, @Param("provider") provider: string) {
|
||||
return this.oauthService.unlink(user, provider);
|
||||
}
|
||||
}
|
||||
57
backend/src/oauth/oauth.module.ts
Normal file
57
backend/src/oauth/oauth.module.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { OAuthController } from "./oauth.controller";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { GitHubProvider } from "./provider/github.provider";
|
||||
import { GoogleProvider } from "./provider/google.provider";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
import { OidcProvider } from "./provider/oidc.provider";
|
||||
import { DiscordProvider } from "./provider/discord.provider";
|
||||
import { MicrosoftProvider } from "./provider/microsoft.provider";
|
||||
|
||||
@Module({
|
||||
controllers: [OAuthController],
|
||||
providers: [
|
||||
OAuthService,
|
||||
GitHubProvider,
|
||||
GoogleProvider,
|
||||
MicrosoftProvider,
|
||||
DiscordProvider,
|
||||
OidcProvider,
|
||||
{
|
||||
provide: "OAUTH_PROVIDERS",
|
||||
useFactory(
|
||||
github: GitHubProvider,
|
||||
google: GoogleProvider,
|
||||
microsoft: MicrosoftProvider,
|
||||
discord: DiscordProvider,
|
||||
oidc: OidcProvider,
|
||||
): Record<string, OAuthProvider<unknown>> {
|
||||
return {
|
||||
github,
|
||||
google,
|
||||
microsoft,
|
||||
discord,
|
||||
oidc,
|
||||
};
|
||||
},
|
||||
inject: [
|
||||
GitHubProvider,
|
||||
GoogleProvider,
|
||||
MicrosoftProvider,
|
||||
DiscordProvider,
|
||||
OidcProvider,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: "OAUTH_PLATFORMS",
|
||||
useFactory(providers: Record<string, OAuthProvider<unknown>>): string[] {
|
||||
return Object.keys(providers);
|
||||
},
|
||||
inject: ["OAUTH_PROVIDERS"],
|
||||
},
|
||||
],
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
exports: [OAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
210
backend/src/oauth/oauth.service.ts
Normal file
210
backend/src/oauth/oauth.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
@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);
|
||||
|
||||
available(): string[] {
|
||||
return this.platforms
|
||||
.map((platform) => [
|
||||
platform,
|
||||
this.config.get(`oauth.${platform}-enabled`),
|
||||
])
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.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: {
|
||||
provider: true,
|
||||
providerUsername: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
|
||||
}
|
||||
|
||||
async signIn(user: OAuthSignInDto, ip: string) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.updateIsAdmin(oauthUser.userId, user.isAdmin);
|
||||
const updatedUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: oauthUser.userId,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
return this.signUp(user, ip);
|
||||
}
|
||||
|
||||
async link(
|
||||
userId: string,
|
||||
provider: string,
|
||||
providerUserId: string,
|
||||
providerUsername: string,
|
||||
) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
provider,
|
||||
providerUserId,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
throw new ErrorPageException("already_linked", "/account", [
|
||||
`provider_${provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
userId,
|
||||
provider,
|
||||
providerUsername,
|
||||
providerUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unlink(user: User, provider: string) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.prisma.oAuthUser.delete({
|
||||
where: {
|
||||
id: oauthUser.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new ErrorPageException("not_linked", "/account", [provider]);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAvailableUsername(preferredUsername: string) {
|
||||
// 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: {
|
||||
username: username,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
username = username + "_" + nanoid(10).replaceAll("-", "");
|
||||
} else {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async signUp(user: OAuthSignInDto, ip: string) {
|
||||
// register
|
||||
if (!this.config.get("oauth.allowRegistration")) {
|
||||
throw new ErrorPageException("no_user", "/auth/signIn", [
|
||||
`provider_${user.provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw new ErrorPageException("no_email", "/auth/signIn", [
|
||||
`provider_${user.provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
const existingUser: User = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId.toString(),
|
||||
providerUsername: user.providerUsername,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
});
|
||||
await this.updateIsAdmin(existingUser.id, user.isAdmin);
|
||||
return this.auth.generateToken(existingUser, { idToken: user.idToken });
|
||||
}
|
||||
|
||||
const result = await this.auth.signUp(
|
||||
{
|
||||
email: user.email,
|
||||
username: await this.getAvailableUsername(user.providerUsername),
|
||||
password: null,
|
||||
},
|
||||
ip,
|
||||
user.isAdmin,
|
||||
);
|
||||
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId.toString(),
|
||||
providerUsername: user.providerUsername,
|
||||
userId: result.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateIsAdmin(userId: string, isAdmin?: boolean) {
|
||||
if (!isAdmin) return;
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
isAdmin: isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
146
backend/src/oauth/provider/discord.provider.ts
Normal file
146
backend/src/oauth/provider/discord.provider.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
@Injectable()
|
||||
export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getAuthEndpoint(state: string): Promise<string> {
|
||||
let scope = "identify email";
|
||||
if (this.config.get("oauth.discord-limitedGuild")) {
|
||||
scope += " guilds";
|
||||
}
|
||||
return Promise.resolve(
|
||||
"https://discord.com/api/oauth2/authorize?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.discord-clientId"),
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||
response_type: "code",
|
||||
state,
|
||||
scope,
|
||||
}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthorizationHeader() {
|
||||
return (
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
this.config.get("oauth.discord-clientId") +
|
||||
":" +
|
||||
this.config.get("oauth.discord-clientSecret"),
|
||||
).toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<DiscordToken>> {
|
||||
const res = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: this.getAuthorizationHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code: query.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||
}),
|
||||
});
|
||||
const token = (await res.json()) as DiscordToken;
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
expiresIn: token.expires_in,
|
||||
scope: token.scope,
|
||||
tokenType: token.token_type,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(token: OAuthToken<DiscordToken>): Promise<OAuthSignInDto> {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const user = (await res.json()) as DiscordUser;
|
||||
if (user.verified === false) {
|
||||
throw new ErrorPageException("unverified_account", undefined, [
|
||||
"provider_discord",
|
||||
]);
|
||||
}
|
||||
|
||||
const guild = this.config.get("oauth.discord-limitedGuild");
|
||||
if (guild) {
|
||||
await this.checkLimitedGuild(token, guild);
|
||||
}
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
|
||||
try {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const guilds = (await res.json()) as DiscordPartialGuild[];
|
||||
if (!guilds.some((guild) => guild.id === guildId)) {
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
} catch {
|
||||
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 {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string;
|
||||
email: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordPartialGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
features: string[];
|
||||
}
|
||||
291
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
291
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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 { 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;
|
||||
private configuration: OidcConfigurationCache;
|
||||
private jwk: OidcJwkCache;
|
||||
private logger: Logger = new Logger(
|
||||
Object.getPrototypeOf(this).constructor.name,
|
||||
);
|
||||
|
||||
protected constructor(
|
||||
protected name: string,
|
||||
protected keyOfConfigUpdateEvents: string[],
|
||||
protected config: ConfigService,
|
||||
protected jwtService: JwtService,
|
||||
protected cache: Cache,
|
||||
) {
|
||||
this.discoveryUri = this.getDiscoveryUri();
|
||||
this.config.addListener("update", (key: string) => {
|
||||
if (this.keyOfConfigUpdateEvents.includes(key)) {
|
||||
this.deinit();
|
||||
this.discoveryUri = this.getDiscoveryUri();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected getRedirectUri(): string {
|
||||
return `${this.config.get("general.appUrl")}/api/oauth/callback/${
|
||||
this.name
|
||||
}`;
|
||||
}
|
||||
|
||||
async getConfiguration(): Promise<OidcConfiguration> {
|
||||
if (!this.configuration || this.configuration.expires < Date.now()) {
|
||||
await this.fetchConfiguration();
|
||||
}
|
||||
return this.configuration.data;
|
||||
}
|
||||
|
||||
async getJwk(): Promise<OidcJwk[]> {
|
||||
if (!this.jwk || this.jwk.expires < Date.now()) {
|
||||
await this.fetchJwk();
|
||||
}
|
||||
return this.jwk.data;
|
||||
}
|
||||
|
||||
async getAuthEndpoint(state: string) {
|
||||
const configuration = await this.getConfiguration();
|
||||
const endpoint = configuration.authorization_endpoint;
|
||||
|
||||
const nonce = nanoid();
|
||||
await this.cache.set(
|
||||
`oauth-${this.name}-nonce-${state}`,
|
||||
nonce,
|
||||
1000 * 60 * 5,
|
||||
);
|
||||
|
||||
return (
|
||||
endpoint +
|
||||
"?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
response_type: "code",
|
||||
scope:
|
||||
this.name == "oidc"
|
||||
? this.config.get(`oauth.oidc-scope`)
|
||||
: "openid email profile",
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
state,
|
||||
nonce,
|
||||
}).toString()
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<OidcToken>> {
|
||||
const configuration = await this.getConfiguration();
|
||||
const endpoint = configuration.token_endpoint;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
client_secret: this.config.get(`oauth.${this.name}-clientSecret`),
|
||||
grant_type: "authorization_code",
|
||||
code: query.code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}).toString(),
|
||||
});
|
||||
const token = (await res.json()) as OidcToken;
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
expiresIn: token.expires_in,
|
||||
idToken: token.id_token,
|
||||
refreshToken: token.refresh_token,
|
||||
tokenType: token.token_type,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(
|
||||
token: OAuthToken<OidcToken>,
|
||||
query: OAuthCallbackDto,
|
||||
claim?: string,
|
||||
roleConfig?: {
|
||||
path?: string;
|
||||
generalAccess?: string;
|
||||
adminAccess?: string;
|
||||
},
|
||||
): Promise<OAuthSignInDto> {
|
||||
const idTokenData = this.decodeIdToken(token.idToken);
|
||||
|
||||
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);
|
||||
await this.cache.del(key);
|
||||
if (nonce !== idTokenData.nonce) {
|
||||
this.logger.error(
|
||||
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
|
||||
);
|
||||
throw new ErrorPageException("invalid_token");
|
||||
}
|
||||
|
||||
const username = claim
|
||||
? idTokenData[claim]
|
||||
: idTokenData.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[] = [];
|
||||
try {
|
||||
const rolesClaim = jmespath.search(idTokenData, roleConfig.path);
|
||||
if (Array.isArray(rolesClaim)) {
|
||||
roles = rolesClaim;
|
||||
}
|
||||
} catch (e) {
|
||||
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) {
|
||||
this.logger.error(
|
||||
`Can not get username from ID Token ${JSON.stringify(
|
||||
idTokenData,
|
||||
undefined,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
throw new ErrorPageException("cannot_get_user_info", undefined, [
|
||||
`provider_${this.name}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: this.name as any,
|
||||
email: idTokenData.email,
|
||||
providerId: idTokenData.sub,
|
||||
providerUsername: username,
|
||||
...(isAdmin !== undefined && { isAdmin }),
|
||||
idToken: `${this.name}:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
protected abstract getDiscoveryUri(): string;
|
||||
|
||||
private async fetchConfiguration(): Promise<void> {
|
||||
const res = await fetch(this.discoveryUri);
|
||||
const expires = res.headers.has("expires")
|
||||
? new Date(res.headers.get("expires")).getTime()
|
||||
: Date.now() + 1000 * 60 * 60 * 24;
|
||||
this.configuration = {
|
||||
expires,
|
||||
data: (await res.json()) as OidcConfiguration,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchJwk(): Promise<void> {
|
||||
const configuration = await this.getConfiguration();
|
||||
const res = await fetch(configuration.jwks_uri);
|
||||
const expires = res.headers.has("expires")
|
||||
? new Date(res.headers.get("expires")).getTime()
|
||||
: Date.now() + 1000 * 60 * 60 * 24;
|
||||
this.jwk = {
|
||||
expires,
|
||||
data: (await res.json())["keys"],
|
||||
};
|
||||
}
|
||||
|
||||
private deinit() {
|
||||
this.discoveryUri = undefined;
|
||||
this.configuration = undefined;
|
||||
this.jwk = undefined;
|
||||
}
|
||||
|
||||
private decodeIdToken(idToken: string): OidcIdToken {
|
||||
return this.jwtService.decode(idToken) as OidcIdToken;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcCache<T> {
|
||||
expires: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface OidcConfiguration {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
response_types_supported: string[];
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
scopes_supported?: string[];
|
||||
claims_supported?: string[];
|
||||
frontchannel_logout_supported?: boolean;
|
||||
end_session_endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OidcJwk {
|
||||
e: string;
|
||||
alg: string;
|
||||
kid: string;
|
||||
use: string;
|
||||
kty: string;
|
||||
n: string;
|
||||
}
|
||||
|
||||
export type OidcConfigurationCache = OidcCache<OidcConfiguration>;
|
||||
|
||||
export type OidcJwkCache = OidcCache<OidcJwk[]>;
|
||||
|
||||
export interface OidcToken {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
export interface OidcIdToken {
|
||||
iss: string;
|
||||
sub: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
email: string;
|
||||
name: string;
|
||||
nickname: string;
|
||||
preferred_username: string;
|
||||
nonce: string;
|
||||
}
|
||||
112
backend/src/oauth/provider/github.provider.ts
Normal file
112
backend/src/oauth/provider/github.provider.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getAuthEndpoint(state: string): Promise<string> {
|
||||
return Promise.resolve(
|
||||
"https://github.com/login/oauth/authorize?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.github-clientId"),
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/github",
|
||||
state: state,
|
||||
scope: "user:email",
|
||||
}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<GitHubToken>> {
|
||||
const res = await fetch(
|
||||
"https://github.com/login/oauth/access_token?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.github-clientId"),
|
||||
client_secret: this.config.get("oauth.github-clientSecret"),
|
||||
code: query.code,
|
||||
}).toString(),
|
||||
{
|
||||
method: "post",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const token = (await res.json()) as GitHubToken;
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
tokenType: token.token_type,
|
||||
scope: token.scope,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
|
||||
if (!token.scope.includes("user:email")) {
|
||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||
}
|
||||
const user = await this.getGitHubUser(token);
|
||||
const email = await this.getGitHubEmail(token);
|
||||
if (!email) {
|
||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "github",
|
||||
providerId: user.id.toString(),
|
||||
providerUsername: user.name ?? user.login,
|
||||
email,
|
||||
idToken: `github:${token.idToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async getGitHubUser(
|
||||
token: OAuthToken<GitHubToken>,
|
||||
): Promise<GitHubUser> {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
return (await res.json()) as GitHubUser;
|
||||
}
|
||||
|
||||
private async getGitHubEmail(
|
||||
token: OAuthToken<GitHubToken>,
|
||||
): Promise<string | undefined> {
|
||||
const res = await fetch("https://api.github.com/user/public_emails", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const emails = (await res.json()) as GitHubEmail[];
|
||||
return emails.find((e) => e.primary && e.verified)?.email;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface GitHubUser {
|
||||
login: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
email?: string; // this filed seems only return null
|
||||
}
|
||||
|
||||
export interface GitHubEmail {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
visibility: string | null;
|
||||
}
|
||||
21
backend/src/oauth/provider/google.provider.ts
Normal file
21
backend/src/oauth/provider/google.provider.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) cache: Cache,
|
||||
) {
|
||||
super("google", ["oauth.google-enabled"], config, jwtService, cache);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return "https://accounts.google.com/.well-known/openid-configuration";
|
||||
}
|
||||
}
|
||||
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) cache: Cache,
|
||||
) {
|
||||
super(
|
||||
"microsoft",
|
||||
["oauth.microsoft-enabled", "oauth.microsoft-tenant"],
|
||||
config,
|
||||
jwtService,
|
||||
cache,
|
||||
);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return `https://login.microsoftonline.com/${this.config.get(
|
||||
"oauth.microsoft-tenant",
|
||||
)}/v2.0/.well-known/openid-configuration`;
|
||||
}
|
||||
}
|
||||
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
|
||||
/**
|
||||
* @typeParam T - type of token
|
||||
* @typeParam C - type of callback query
|
||||
*/
|
||||
export interface OAuthProvider<T, C = OAuthCallbackDto> {
|
||||
getAuthEndpoint(state: string): Promise<string>;
|
||||
|
||||
getToken(query: C): Promise<OAuthToken<T>>;
|
||||
|
||||
getUserInfo(token: OAuthToken<T>, query: C): Promise<OAuthSignInDto>;
|
||||
}
|
||||
|
||||
export interface OAuthToken<T> {
|
||||
accessToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType?: string;
|
||||
scope?: string;
|
||||
idToken?: string;
|
||||
rawToken: T;
|
||||
}
|
||||
48
backend/src/oauth/provider/oidc.provider.ts
Normal file
48
backend/src/oauth/provider/oidc.provider.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { GenericOidcProvider, OidcToken } from "./genericOidc.provider";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OidcProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) protected cache: Cache,
|
||||
) {
|
||||
super(
|
||||
"oidc",
|
||||
["oauth.oidc-enabled", "oauth.oidc-discoveryUri"],
|
||||
config,
|
||||
jwtService,
|
||||
cache,
|
||||
);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return this.config.get("oauth.oidc-discoveryUri");
|
||||
}
|
||||
|
||||
getUserInfo(
|
||||
token: OAuthToken<OidcToken>,
|
||||
query: OAuthCallbackDto,
|
||||
_?: string,
|
||||
): Promise<OAuthSignInDto> {
|
||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
||||
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;
|
||||
return super.getUserInfo(token, query, claim, {
|
||||
path: rolePath,
|
||||
generalAccess: roleGeneralAccess,
|
||||
adminAccess: roleAdminAccess,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||
shares: Omit<
|
||||
MyShareDTO,
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword" | "size"
|
||||
>[];
|
||||
|
||||
@Expose()
|
||||
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
return partial.map((part) =>
|
||||
plainToClass(ReverseShareTokenWithShares, part, {
|
||||
excludeExtraneousValues: true,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
|
||||
export class ReverseShareController {
|
||||
constructor(
|
||||
private reverseShareService: ReverseShareService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -36,7 +36,12 @@ export class ReverseShareController {
|
||||
return { token, link };
|
||||
}
|
||||
|
||||
@Throttle(20, 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Get(":reverseShareToken")
|
||||
async getByToken(@Param("reverseShareToken") reverseShareToken: string) {
|
||||
const isValid = await this.reverseShareService.isValid(reverseShareToken);
|
||||
@@ -44,7 +49,7 @@ export class ReverseShareController {
|
||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||
|
||||
return new ReverseShareDTO().from(
|
||||
await this.reverseShareService.getByToken(reverseShareToken)
|
||||
await this.reverseShareService.getByToken(reverseShareToken),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +57,7 @@ export class ReverseShareController {
|
||||
@UseGuards(JwtGuard)
|
||||
async getAllByUser(@GetUser() user: User) {
|
||||
return new ReverseShareTokenWithShares().fromList(
|
||||
await this.reverseShareService.getAllByUser(user.id)
|
||||
await this.reverseShareService.getAllByUser(user.id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -10,7 +11,7 @@ export class ReverseShareService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||
@@ -19,16 +20,28 @@ export class ReverseShareService {
|
||||
.add(
|
||||
data.shareExpiration.split("-")[0],
|
||||
data.shareExpiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
"-",
|
||||
)[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.toDate();
|
||||
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
maxExpiration.value !== 0 &&
|
||||
parsedExpiration >
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
);
|
||||
}
|
||||
|
||||
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||
|
||||
if (globalMaxShareSize < data.maxShareSize)
|
||||
throw new BadRequestException(
|
||||
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
|
||||
`Max share size can't be greater than ${globalMaxShareSize} bytes.`,
|
||||
);
|
||||
|
||||
const reverseShare = await this.prisma.reverseShare.create({
|
||||
@@ -37,6 +50,8 @@ export class ReverseShareService {
|
||||
remainingUses: data.maxUseCount,
|
||||
maxShareSize: data.maxShareSize,
|
||||
sendEmailNotification: data.sendEmailNotification,
|
||||
simplified: data.simplified,
|
||||
publicAccess: data.publicAccess,
|
||||
creatorId,
|
||||
},
|
||||
});
|
||||
|
||||
27
backend/src/share/dto/adminShare.dto.ts
Normal file
27
backend/src/share/dto/adminShare.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
|
||||
export class AdminShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
"from",
|
||||
"fromList",
|
||||
] as const) {
|
||||
@Expose()
|
||||
views: number;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
from(partial: Partial<AdminShareDTO>) {
|
||||
return plainToClass(AdminShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
fromList(partial: Partial<AdminShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(AdminShareDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export class CreateShareDTO {
|
||||
@Length(3, 50)
|
||||
id: string;
|
||||
|
||||
@Length(3, 30)
|
||||
@IsOptional()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
expiration: string;
|
||||
|
||||
|
||||
@@ -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,13 +22,16 @@ 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 });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<MyShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(MyShareDTO, part, { 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;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export class ShareDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name?: string;
|
||||
|
||||
@Expose()
|
||||
expiration: Date;
|
||||
|
||||
@@ -23,13 +26,16 @@ export class ShareDTO {
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
@Expose()
|
||||
size: number;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<ShareDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ShareDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(ShareDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
export class CreateShareGuard extends JwtGuard {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private reverseShareService: ReverseShareService
|
||||
private reverseShareService: ReverseShareService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
@@ -20,9 +20,8 @@ export class CreateShareGuard extends JwtGuard {
|
||||
|
||||
if (!reverseShareTokenId) return false;
|
||||
|
||||
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
||||
reverseShareTokenId
|
||||
);
|
||||
const isReverseShareTokenValid =
|
||||
await this.reverseShareService.isValid(reverseShareTokenId);
|
||||
|
||||
return isReverseShareTokenValid;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
||||
|
||||
@Injectable()
|
||||
export class ShareOwnerGuard implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
export class ShareOwnerGuard extends JwtGuard {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -28,8 +34,20 @@ export class ShareOwnerGuard implements CanActivate {
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
|
||||
// Run the JWTGuard to set the user
|
||||
await super.canActivate(context);
|
||||
const user = request.user as User;
|
||||
|
||||
// If the user is an admin, allow access
|
||||
if (user?.isAdmin) return true;
|
||||
|
||||
// If it's a anonymous share, allow access
|
||||
if (!share.creatorId) return true;
|
||||
|
||||
return share.creatorId == (request.user as User).id;
|
||||
// If not signed in, deny access
|
||||
if (!user) return false;
|
||||
|
||||
// If the user is the creator of the share, allow access
|
||||
return share.creatorId == user.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
@@ -9,20 +8,26 @@ 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
|
||||
) {}
|
||||
private prisma: PrismaService,
|
||||
configService: ConfigService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -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 (
|
||||
@@ -44,13 +49,29 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
if (share.security?.password && !shareToken)
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required"
|
||||
"share_password_required",
|
||||
);
|
||||
|
||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||
throw new ForbiddenException(
|
||||
"Share token required",
|
||||
"share_token_required"
|
||||
"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;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import * as moment from "moment";
|
||||
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 { AdminShareDTO } from "./dto/adminShare.dto";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
import { MyShareDTO } from "./dto/myShare.dto";
|
||||
import { ShareDTO } from "./dto/share.dto";
|
||||
@@ -25,15 +29,25 @@ 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(private shareService: ShareService) {}
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@Get("all")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getAllShares() {
|
||||
return new AdminShareDTO().fromList(await this.shareService.getShares());
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
async getMyShares(@GetUser() user: User) {
|
||||
return new MyShareDTO().fromList(
|
||||
await this.shareService.getSharesByUser(user.id)
|
||||
await this.shareService.getSharesByUser(user.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +57,12 @@ export class ShareController {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/from-owner")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async getFromOwner(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/metaData")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getMetaData(@Param("id") id: string) {
|
||||
@@ -54,46 +74,66 @@ export class ShareController {
|
||||
async create(
|
||||
@Body() body: CreateShareDTO,
|
||||
@Req() request: Request,
|
||||
@GetUser() user: User
|
||||
@GetUser() user: User,
|
||||
) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new ShareDTO().from(
|
||||
await this.shareService.create(body, user, reverse_share_token)
|
||||
await this.shareService.create(body, user, reverse_share_token),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||
async remove(@Param("id") id: string) {
|
||||
await this.shareService.remove(id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@HttpCode(202)
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
return new ShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token)
|
||||
return new CompletedShareDTO().from(
|
||||
await this.shareService.complete(id, reverse_share_token),
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle(10, 60)
|
||||
@Delete(":id/complete")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async revertComplete(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.revertComplete(id));
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async remove(@Param("id") id: string, @GetUser() user: User) {
|
||||
const isDeleterAdmin = user?.isAdmin === true;
|
||||
await this.shareService.remove(id, isDeleterAdmin);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Get("isShareIdAvailable/:id")
|
||||
async isShareIdAvailable(@Param("id") id: string) {
|
||||
return this.shareService.isShareIdAvailable(id);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Throttle(20, 5 * 60)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@UseGuards(ShareTokenSecurity)
|
||||
@Post(":id/token")
|
||||
async getShareToken(
|
||||
@Param("id") id: string,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: SharePasswordDto
|
||||
@Body() body: SharePasswordDto,
|
||||
) {
|
||||
const token = await this.shareService.getShareToken(id, body.password);
|
||||
|
||||
this.clearShareTokenCookies(request, response);
|
||||
response.cookie(`share_${id}_token`, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -101,4 +141,32 @@ export class ShareController {
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the 10 most recent share token cookies and deletes the rest and all expired ones
|
||||
*/
|
||||
private clearShareTokenCookies(request: Request, response: Response) {
|
||||
const shareTokenCookies = Object.entries(request.cookies)
|
||||
.filter(([key]) => key.startsWith("share_") && key.endsWith("_token"))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
payload: this.jwtService.decode(value),
|
||||
}));
|
||||
|
||||
const expiredTokens = shareTokenCookies.filter(
|
||||
(cookie) => cookie.payload.exp < moment().unix(),
|
||||
);
|
||||
const validTokens = shareTokenCookies.filter(
|
||||
(cookie) => cookie.payload.exp >= moment().unix(),
|
||||
);
|
||||
|
||||
expiredTokens.forEach((cookie) => response.clearCookie(cookie.key));
|
||||
|
||||
if (validTokens.length > 10) {
|
||||
validTokens
|
||||
.sort((a, b) => a.payload.exp - b.payload.exp)
|
||||
.slice(0, -10)
|
||||
.forEach((cookie) => response.clearCookie(cookie.key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ShareService } from "./share.service";
|
||||
imports: [
|
||||
JwtModule.register({}),
|
||||
EmailModule,
|
||||
ClamScanModule,
|
||||
forwardRef(() => ClamScanModule),
|
||||
ReverseShareModule,
|
||||
forwardRef(() => FileModule),
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { JwtService, JwtSignOptions } from "@nestjs/jwt";
|
||||
import { Share, User } from "@prisma/client";
|
||||
import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@@ -23,12 +24,13 @@ 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,
|
||||
private jwtService: JwtService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private clamScanService: ClamScanService
|
||||
private clamScanService: ClamScanService,
|
||||
) {}
|
||||
|
||||
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
||||
@@ -45,25 +47,28 @@ export class ShareService {
|
||||
let expirationDate: Date;
|
||||
|
||||
// If share is created by a reverse share token override the expiration date
|
||||
const reverseShare = await this.reverseShareService.getByToken(
|
||||
reverseShareToken
|
||||
);
|
||||
const reverseShare =
|
||||
await this.reverseShareService.getByToken(reverseShareToken);
|
||||
if (reverseShare) {
|
||||
expirationDate = reverseShare.shareExpiration;
|
||||
} else {
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
} else {
|
||||
expirationDate = moment(0).toDate();
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(share.expiration);
|
||||
|
||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
||||
|
||||
const maxExpiration = this.config.get("share.maxExpiration");
|
||||
if (
|
||||
maxExpiration.value !== 0 &&
|
||||
(expiresNever ||
|
||||
parsedExpiration >
|
||||
moment().add(maxExpiration.value, maxExpiration.unit).toDate())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
);
|
||||
}
|
||||
|
||||
expirationDate = parsedExpiration;
|
||||
}
|
||||
|
||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||
@@ -81,6 +86,7 @@ export class ShareService {
|
||||
? share.recipients.map((email) => ({ email }))
|
||||
: [],
|
||||
},
|
||||
storageProvider: this.configService.get("s3.enabled") ? "S3" : "LOCAL",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,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 } });
|
||||
@@ -134,13 +142,13 @@ export class ShareService {
|
||||
|
||||
if (share.files.length == 0)
|
||||
throw new BadRequestException(
|
||||
"You need at least on file in your share to complete it."
|
||||
"You need at least on file in your share to complete it.",
|
||||
);
|
||||
|
||||
// Asynchronously create a zip of all files
|
||||
if (share.files.length > 1)
|
||||
this.createZip(id).then(() =>
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
|
||||
);
|
||||
|
||||
// Send email for each recipient
|
||||
@@ -150,18 +158,19 @@ export class ShareService {
|
||||
share.id,
|
||||
share.creator,
|
||||
share.description,
|
||||
share.expiration
|
||||
share.expiration,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
share.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,10 +184,38 @@ 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) {
|
||||
return this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: false, isZipReady: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getShares() {
|
||||
const shares = await this.prisma.share.findMany({
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { files: true, creator: true },
|
||||
});
|
||||
|
||||
return shares.map((share) => {
|
||||
return {
|
||||
...share,
|
||||
size: share.files.reduce((acc, file) => acc + parseInt(file.size), 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getSharesByUser(userId: string) {
|
||||
@@ -195,13 +232,18 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true, files: true },
|
||||
include: { recipients: true, files: true, security: true },
|
||||
});
|
||||
|
||||
return shares.map((share) => {
|
||||
return {
|
||||
...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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -210,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,
|
||||
},
|
||||
@@ -238,13 +284,14 @@ export class ShareService {
|
||||
return share;
|
||||
}
|
||||
|
||||
async remove(shareId: string) {
|
||||
async remove(shareId: string, isDeleterAdmin = false) {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException("Share not found");
|
||||
if (!share.creatorId)
|
||||
|
||||
if (!share.creatorId && !isDeleterAdmin)
|
||||
throw new ForbiddenException("Anonymous shares can't be deleted");
|
||||
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
@@ -275,17 +322,27 @@ 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) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
"share_max_views_exceeded",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,22 +352,29 @@ 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 },
|
||||
});
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
shareId,
|
||||
},
|
||||
{
|
||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
);
|
||||
|
||||
const tokenPayload = {
|
||||
shareId,
|
||||
shareCreatedAt: moment(createdAt).unix(),
|
||||
iat: moment().unix(),
|
||||
};
|
||||
|
||||
const tokenOptions: JwtSignOptions = {
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
};
|
||||
|
||||
if (!moment(expiration).isSame(0)) {
|
||||
tokenOptions.expiresIn = moment(expiration).diff(new Date(), "seconds");
|
||||
}
|
||||
|
||||
return this.jwtService.sign(tokenPayload, tokenOptions);
|
||||
}
|
||||
|
||||
async verifyShareToken(shareId: string, token: string) {
|
||||
const { expiration } = await this.prisma.share.findUnique({
|
||||
const { expiration, createdAt } = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
});
|
||||
|
||||
@@ -321,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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||
import { PartialType, PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class UpdateOwnUserDTO extends PartialType(
|
||||
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||
PickType(UserDTO, ["username", "email"] as const),
|
||||
) {}
|
||||
|
||||
@@ -16,22 +16,32 @@ export class UserDTO {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@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,37 +24,51 @@ 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")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user: User) {
|
||||
return new UserDTO().from(user);
|
||||
async getCurrentUser(@GetUser() user?: User) {
|
||||
if (!user) return null;
|
||||
const userDTO = new UserDTO().from(user);
|
||||
userDTO.hasPassword = !!user.password;
|
||||
return userDTO;
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async updateCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Body() data: UpdateOwnUserDTO
|
||||
@Body() data: UpdateOwnUserDTO,
|
||||
) {
|
||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@HttpCode(204)
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@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
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Module } from "@nestjs/common";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { UserController } from "./user.controller";
|
||||
import { UserSevice } from "./user.service";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
|
||||
@Module({
|
||||
imports: [EmailModule],
|
||||
imports: [EmailModule, FileModule],
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
exports: [UserSevice],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -1,17 +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 emailService: EmailService,
|
||||
private fileService: FileService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
@@ -46,7 +55,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +75,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +83,146 @@ export class UserSevice {
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { shares: true },
|
||||
});
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/src/utils/date.util.ts
Normal file
29
backend/src/utils/date.util.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as moment from "moment";
|
||||
|
||||
export function parseRelativeDateToAbsolute(relativeDate: string) {
|
||||
if (relativeDate == "never") return moment(0).toDate();
|
||||
|
||||
return moment()
|
||||
.add(
|
||||
relativeDate.split("-")[0],
|
||||
relativeDate.split("-")[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.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}`;
|
||||
}
|
||||
@@ -432,7 +432,7 @@
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
@@ -626,7 +626,7 @@
|
||||
" const responseBody = pm.response.json();",
|
||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||
" pm.expect(responseBody).to.have.property(\"expiration\")",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(3)",
|
||||
" pm.expect(Object.keys(responseBody).length).be.equal(4)",
|
||||
"});",
|
||||
""
|
||||
],
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"target": "es2021",
|
||||
"lib": [
|
||||
"ES2021"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
|
||||
230
config.example.yaml
Normal file
230
config.example.yaml
Normal file
@@ -0,0 +1,230 @@
|
||||
#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"
|
||||
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: ""
|
||||
#The text which should be shown in the company sharing policy. Supports Markdown.
|
||||
companySharingPolicy: ""
|
||||
#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: ""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user