Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
backend/dist/
|
||||
backend/node_modules/
|
||||
backend/data
|
||||
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
|
||||
**/.git/
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
github: stonith404
|
||||
44
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: "Submit a bug report to help us improve"
|
||||
title: "🐛 Bug Report: "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out our bug report form 🙏
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "👟 Reproduction steps"
|
||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||
placeholder: "When I ..."
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "👍 Expected behavior"
|
||||
description: "What did you think would happen?"
|
||||
placeholder: "It should ..."
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "👎 Actual Behavior"
|
||||
description: "What did actually happen? Add screenshots, if applicable."
|
||||
placeholder: "It actually ..."
|
||||
- type: textarea
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: "📜 Logs"
|
||||
description: "Paste any relevant logs here."
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, please check if the issues hasn't been raised before.
|
||||
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
|
||||
29
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: 🚀 Feature
|
||||
description: "Submit a proposal for a new feature"
|
||||
title: "🚀 Feature: "
|
||||
labels: [feature]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out our feature request form 🙏
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "🔖 Feature description"
|
||||
description: "A clear and concise description of what the feature is."
|
||||
placeholder: "You should add ..."
|
||||
- type: textarea
|
||||
id: pitch
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "🎤 Pitch"
|
||||
description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable."
|
||||
placeholder: "In my use-case, ..."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, please check if the issues hasn't been raised before.
|
||||
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
Normal file
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: "🌐 Language request"
|
||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||
title: "🌐 Language request: <language name in english>"
|
||||
labels: [language-request]
|
||||
body:
|
||||
- type: input
|
||||
id: language-name-native
|
||||
attributes:
|
||||
label: "🌐 Language name (native)"
|
||||
placeholder: "Schweizerdeutsch"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: language-code
|
||||
attributes:
|
||||
label: "🌐 Language code"
|
||||
placeholder: "de-CH"
|
||||
validations:
|
||||
required: true
|
||||
22
.github/workflows/backend-system-tests.yml
vendored
Normal file
22
.github/workflows/backend-system-tests.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Backend system tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
system-tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Dependencies
|
||||
working-directory: ./backend
|
||||
run: npm install
|
||||
- name: Run Server and Test with Newman
|
||||
working-directory: ./backend
|
||||
run: npm run test:system
|
||||
54
.github/workflows/build-docker-image.yml
vendored
Normal file
54
.github/workflows/build-docker-image.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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 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
|
||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# next.js
|
||||
/frontend/.next/
|
||||
/frontend/out/
|
||||
|
||||
# yarn
|
||||
yarn.lock
|
||||
|
||||
# build
|
||||
build/
|
||||
dist/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env file
|
||||
.env
|
||||
!/backend/prisma/.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# PWA
|
||||
/frontend/public/workbox-*
|
||||
/frontend/public/sw.*
|
||||
|
||||
# project specific
|
||||
/backend/data/
|
||||
/data/
|
||||
/docs/build/
|
||||
/docs/.docusaurus
|
||||
/docs/.cache-loader
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains specific (webstorm)
|
||||
.idea/**/**
|
||||
13
404.html
13
404.html
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr" class="docs-wrapper plugin-docs plugin-id-default docs-version-current" data-has-hydrated="false">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="generator" content="Docusaurus v3.5.2">
|
||||
<title data-rh="true">Pingvin Share</title><meta data-rh="true" property="og:title" content="Pingvin Share"><meta data-rh="true" name="viewport" content="width=device-width,initial-scale=1"><meta data-rh="true" name="twitter:card" content="summary_large_image"><meta data-rh="true" property="og:image" content="https://stonith404.github.io/pingvin-share/img/pingvinshare.svg"><meta data-rh="true" name="twitter:image" content="https://stonith404.github.io/pingvin-share/img/pingvinshare.svg"><meta data-rh="true" property="og:url" content="https://stonith404.github.io/pingvin-share/404.html"><meta data-rh="true" property="og:locale" content="en"><meta data-rh="true" name="docusaurus_locale" content="en"><meta data-rh="true" name="docsearch:language" content="en"><meta data-rh="true" name="docusaurus_version" content="current"><meta data-rh="true" name="docusaurus_tag" content="docs-default-current"><meta data-rh="true" name="docsearch:version" content="current"><meta data-rh="true" name="docsearch:docusaurus_tag" content="docs-default-current"><link data-rh="true" rel="icon" href="/pingvin-share/img/pingvinshare.svg"><link data-rh="true" rel="canonical" href="https://stonith404.github.io/pingvin-share/404.html"><link data-rh="true" rel="alternate" href="https://stonith404.github.io/pingvin-share/404.html" hreflang="en"><link data-rh="true" rel="alternate" href="https://stonith404.github.io/pingvin-share/404.html" hreflang="x-default"><link rel="stylesheet" href="/pingvin-share/assets/css/styles.b116edeb.css">
|
||||
<script src="/pingvin-share/assets/js/runtime~main.3530f7f6.js" defer="defer"></script>
|
||||
<script src="/pingvin-share/assets/js/main.37696e6b.js" defer="defer"></script>
|
||||
</head>
|
||||
<body class="navigation-with-keyboard">
|
||||
<script>!function(){function t(t){document.documentElement.setAttribute("data-theme",t)}var e=function(){try{return new URLSearchParams(window.location.search).get("docusaurus-theme")}catch(t){}}()||function(){try{return window.localStorage.getItem("theme")}catch(t){}}();null!==e?t(e):window.matchMedia("(prefers-color-scheme: dark)").matches?t("dark"):(window.matchMedia("(prefers-color-scheme: light)").matches,t("light"))}(),function(){try{const c=new URLSearchParams(window.location.search).entries();for(var[t,e]of c)if(t.startsWith("docusaurus-data-")){var a=t.replace("docusaurus-data-","data-");document.documentElement.setAttribute(a,e)}}catch(t){}}()</script><div id="__docusaurus"><div role="region" aria-label="Skip to main content"><a class="skipToContent_fXgn" href="#__docusaurus_skipToContent_fallback">Skip to main content</a></div><nav aria-label="Main" class="navbar navbar--fixed-top"><div class="navbar__inner"><div class="navbar__items"><button aria-label="Toggle navigation bar" aria-expanded="false" class="navbar__toggle clean-btn" type="button"><svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M4 7h22M4 15h22M4 23h22"></path></svg></button><a class="navbar__brand" href="/pingvin-share/"><div class="navbar__logo"><img src="/pingvin-share/img/pingvinshare.svg" alt="Pingvin Share Logo" class="themedComponent_mlkZ themedComponent--light_NVdE"><img src="/pingvin-share/img/pingvinshare.svg" alt="Pingvin Share Logo" class="themedComponent_mlkZ themedComponent--dark_xIcU"></div><b class="navbar__title text--truncate">Pingvin Share</b></a></div><div class="navbar__items navbar__items--right"><a href="https://github.com/stonith404/pingvin-share" target="_blank" rel="noopener noreferrer" class="navbar__item navbar__link">GitHub<svg width="13.5" height="13.5" aria-hidden="true" viewBox="0 0 24 24" class="iconExternalLink_nPIU"><path fill="currentColor" d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path></svg></a><div class="toggle_vylO colorModeToggle_DEke"><button class="clean-btn toggleButton_gllP toggleButtonDisabled_aARS" type="button" disabled="" title="Switch between dark and light mode (currently light mode)" aria-label="Switch between dark and light mode (currently light mode)" aria-live="polite"><svg viewBox="0 0 24 24" width="24" height="24" class="lightToggleIcon_pyhR"><path fill="currentColor" d="M12,9c1.65,0,3,1.35,3,3s-1.35,3-3,3s-3-1.35-3-3S10.35,9,12,9 M12,7c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5 S14.76,7,12,7L12,7z M2,13l2,0c0.55,0,1-0.45,1-1s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S1.45,13,2,13z M20,13l2,0c0.55,0,1-0.45,1-1 s-0.45-1-1-1l-2,0c-0.55,0-1,0.45-1,1S19.45,13,20,13z M11,2v2c0,0.55,0.45,1,1,1s1-0.45,1-1V2c0-0.55-0.45-1-1-1S11,1.45,11,2z M11,20v2c0,0.55,0.45,1,1,1s1-0.45,1-1v-2c0-0.55-0.45-1-1-1C11.45,19,11,19.45,11,20z M5.99,4.58c-0.39-0.39-1.03-0.39-1.41,0 c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0s0.39-1.03,0-1.41L5.99,4.58z M18.36,16.95 c-0.39-0.39-1.03-0.39-1.41,0c-0.39,0.39-0.39,1.03,0,1.41l1.06,1.06c0.39,0.39,1.03,0.39,1.41,0c0.39-0.39,0.39-1.03,0-1.41 L18.36,16.95z M19.42,5.99c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06c-0.39,0.39-0.39,1.03,0,1.41 s1.03,0.39,1.41,0L19.42,5.99z M7.05,18.36c0.39-0.39,0.39-1.03,0-1.41c-0.39-0.39-1.03-0.39-1.41,0l-1.06,1.06 c-0.39,0.39-0.39,1.03,0,1.41s1.03,0.39,1.41,0L7.05,18.36z"></path></svg><svg viewBox="0 0 24 24" width="24" height="24" class="darkToggleIcon_wfgR"><path fill="currentColor" d="M9.37,5.51C9.19,6.15,9.1,6.82,9.1,7.5c0,4.08,3.32,7.4,7.4,7.4c0.68,0,1.35-0.09,1.99-0.27C17.45,17.19,14.93,19,12,19 c-3.86,0-7-3.14-7-7C5,9.07,6.81,6.55,9.37,5.51z M12,3c-4.97,0-9,4.03-9,9s4.03,9,9,9s9-4.03,9-9c0-0.46-0.04-0.92-0.1-1.36 c-0.98,1.37-2.58,2.26-4.4,2.26c-2.98,0-5.4-2.42-5.4-5.4c0-1.81,0.89-3.42,2.26-4.4C12.92,3.04,12.46,3,12,3L12,3z"></path></svg></button></div><div class="navbarSearchContainer_Bca1"></div></div></div><div role="presentation" class="navbar-sidebar__backdrop"></div></nav><div id="__docusaurus_skipToContent_fallback" class="main-wrapper mainWrapper_z2l0"><main class="container margin-vert--xl"><div class="row"><div class="col col--6 col--offset-3"><h1 class="hero__title">Page Not Found</h1><p>We could not find what you were looking for.</p><p>Please contact the owner of the site that linked you to the original URL and let them know their link is broken.</p></div></div></main></div></div>
|
||||
</body>
|
||||
</html>
|
||||
1054
CHANGELOG.md
Normal file
1054
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
95
CONTRIBUTING.md
Normal file
95
CONTRIBUTING.md
Normal file
@@ -0,0 +1,95 @@
|
||||
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
|
||||
## Getting started
|
||||
|
||||
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
||||
|
||||
## Submit a Pull Request
|
||||
|
||||
Before you submit the pull request for review please ensure that
|
||||
|
||||
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||
|
||||
`<type>[optional scope]: <description>`
|
||||
|
||||
example:
|
||||
|
||||
```
|
||||
feat(share): add password protection
|
||||
```
|
||||
|
||||
When `TYPE` can be:
|
||||
|
||||
- **feat** - is a new feature
|
||||
- **doc** - documentation only changes
|
||||
- **fix** - a bug fix
|
||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||
|
||||
- Your pull request has a detailed description
|
||||
- You run `npm run format` to format the code
|
||||
|
||||
<details>
|
||||
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
|
||||
|
||||
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
|
||||
|
||||
2. Clone your fork to your machine with `git clone`
|
||||
|
||||
```
|
||||
$ git clone https://github.com/[your_username]/pingvin-share
|
||||
```
|
||||
|
||||
3. Work - commit - repeat
|
||||
|
||||
4. Push changes to GitHub
|
||||
|
||||
```
|
||||
$ git push origin [name_of_your_new_branch]
|
||||
```
|
||||
|
||||
5. Submit your changes for review
|
||||
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
||||
6. Start a Pull Request
|
||||
7. Now submit the pull request and click on `Create pull request`.
|
||||
8. Get a code review approval/reject
|
||||
|
||||
</details>
|
||||
|
||||
## Setup project
|
||||
|
||||
Pingvin Share consists of a frontend and a backend.
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. Install the dependencies with `npm install`
|
||||
3. Push the database schema to the database by running `npx prisma db push`
|
||||
4. Seed the database with `npx prisma db seed`
|
||||
5. Start the backend with `npm run dev`
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Start the backend first
|
||||
2. Open the `frontend` folder
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
You're all set!
|
||||
|
||||
### Testing
|
||||
|
||||
At the moment we only have system tests for the backend. To run these tests, run `npm run test:system` in the backend folder.
|
||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# Stage 1: Frontend dependencies
|
||||
FROM node:20-alpine AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
RUN apk add --no-cache python3
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4: Build backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
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
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=docker
|
||||
|
||||
# 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
|
||||
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
||||
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
||||
COPY --from=frontend-builder /opt/app/public/img /tmp/img
|
||||
|
||||
WORKDIR /opt/app/backend
|
||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||
COPY --from=backend-builder /opt/app/dist ./dist
|
||||
COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
COPY --from=backend-builder /opt/app/tsconfig.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
COPY ./reverse-proxy /opt/app/reverse-proxy
|
||||
COPY ./scripts/docker ./scripts/docker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
25
LICENSE
Normal file
25
LICENSE
Normal file
@@ -0,0 +1,25 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2022, Elias Schneider
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
[](https://discord.gg/wHRQ9nFRcK) [](https://crowdin.com/project/pingvin-share) [](https://github.com/sponsors/stonith404)
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is a self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Share files using a link
|
||||
- Unlimited file size (restricted only by disk space)
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Reverse shares
|
||||
- OIDC and LDAP authentication
|
||||
- Integration with ClamAV for security scans
|
||||
- Different file providers: local storage and S3
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ Setup
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
1. Download the `docker-compose.yml` file
|
||||
2. Run `docker compose up -d`
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
> [!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.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For more installation options and advanced configurations, please refer to the [documentation](https://stonith404.github.io/pingvin-share).
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
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).
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
Thank you for supporting Pingvin Share 🙏
|
||||
|
||||
- [@COMPLEXWASTAKEN](https://github.com/COMPLEXWASTAKEN)
|
||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
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).
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[583],{6866:(n,i,s)=>{s.r(i),s.d(i,{default:()=>t});s(6540);var e=s(6347),r=s(4848);function t(){return(0,r.jsx)(e.rd,{to:"/pingvin-share/introduction"})}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[237],{3363:(e,t,n)=>{n.d(t,{A:()=>a});n(6540);var i=n(4164),o=n(1312),s=n(1107),r=n(4848);function a(e){let{className:t}=e;return(0,r.jsx)("main",{className:(0,i.A)("container margin-vert--xl",t),children:(0,r.jsx)("div",{className:"row",children:(0,r.jsxs)("div",{className:"col col--6 col--offset-3",children:[(0,r.jsx)(s.A,{as:"h1",className:"hero__title",children:(0,r.jsx)(o.A,{id:"theme.NotFound.title",description:"The title of the 404 page",children:"Page Not Found"})}),(0,r.jsx)("p",{children:(0,r.jsx)(o.A,{id:"theme.NotFound.p1",description:"The first paragraph of the 404 page",children:"We could not find what you were looking for."})}),(0,r.jsx)("p",{children:(0,r.jsx)(o.A,{id:"theme.NotFound.p2",description:"The 2nd paragraph of the 404 page",children:"Please contact the owner of the site that linked you to the original URL and let them know their link is broken."})})]})})})}},2237:(e,t,n)=>{n.r(t),n.d(t,{default:()=>d});n(6540);var i=n(1312),o=n(1003),s=n(781),r=n(3363),a=n(4848);function d(){const e=(0,i.T)({id:"theme.NotFound.title",message:"Page Not Found"});return(0,a.jsxs)(a.Fragment,{children:[(0,a.jsx)(o.be,{title:e}),(0,a.jsx)(s.A,{children:(0,a.jsx)(r.A,{})})]})}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[361],{2744:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>d,contentTitle:()=>s,default:()=>p,frontMatter:()=>a,metadata:()=>o,toc:()=>c});var r=t(4848),i=t(8453);const a={id:"upgrading"},s="Upgrading",o={id:"setup/upgrading",title:"Upgrading",description:"Upgrade to a new version",source:"@site/docs/setup/upgrading.md",sourceDirName:"setup",slug:"/setup/upgrading",permalink:"/pingvin-share/setup/upgrading",draft:!1,unlisted:!1,editUrl:"https://github.com/stonith404/pingvin-share/edit/main/docs/docs/setup/upgrading.md",tags:[],version:"current",frontMatter:{id:"upgrading"},sidebar:"docsSidebar",previous:{title:"S3",permalink:"/pingvin-share/setup/s3"},next:{title:"Translating",permalink:"/pingvin-share/help-out/translate"}},d={},c=[{value:"Upgrade to a new version",id:"upgrade-to-a-new-version",level:3},{value:"Docker",id:"docker",level:4},{value:"Portainer",id:"portainer",level:3},{value:"Stand-alone",id:"stand-alone",level:4}];function l(e){const n={a:"a",code:"code",h1:"h1",h3:"h3",h4:"h4",header:"header",li:"li",ol:"ol",p:"p",pre:"pre",...(0,i.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(n.header,{children:(0,r.jsx)(n.h1,{id:"upgrading",children:"Upgrading"})}),"\n",(0,r.jsx)(n.h3,{id:"upgrade-to-a-new-version",children:"Upgrade to a new version"}),"\n",(0,r.jsx)(n.p,{children:"As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading."}),"\n",(0,r.jsx)(n.h4,{id:"docker",children:"Docker"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"docker compose pull\ndocker compose up -d\n"})}),"\n",(0,r.jsx)(n.h3,{id:"portainer",children:"Portainer"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsx)(n.li,{children:"In your container page, click on Recreate."}),"\n",(0,r.jsx)(n.li,{children:"Check the Re-Pull image toggle."}),"\n",(0,r.jsx)(n.li,{children:"Click on Recreate."}),"\n"]}),"\n",(0,r.jsx)(n.h4,{id:"stand-alone",children:"Stand-alone"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["\n",(0,r.jsx)(n.p,{children:"Stop the running app"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"pm2 stop pingvin-share-backend pingvin-share-frontend\n"})}),"\n"]}),"\n",(0,r.jsxs)(n.li,{children:["\n",(0,r.jsxs)(n.p,{children:["Repeat the steps from the ",(0,r.jsx)(n.a,{href:"#stand-alone-installation",children:"installation guide"})," except the ",(0,r.jsx)(n.code,{children:"git clone"})," step."]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"cd pingvin-share\n\n# Checkout the latest version\ngit fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)\n\n# Start the backend\ncd backend\nnpm install\nnpm run build\npm2 restart pingvin-share-backend\n\n# Start the frontend\ncd ../frontend\nnpm install\nnpm run build\npm2 restart pingvin-share-frontend\n"})}),"\n"]}),"\n"]}),"\n",(0,r.jsxs)(n.p,{children:["Note that environemnt variables are not picked up when using pm2 restart, if you actually want to change configs, you need to run ",(0,r.jsx)(n.code,{children:"pm2 --update-env restart"})]})]})}function p(e={}){const{wrapper:n}={...(0,i.R)(),...e.components};return n?(0,r.jsx)(n,{...e,children:(0,r.jsx)(l,{...e})}):l(e)}},8453:(e,n,t)=>{t.d(n,{R:()=>s,x:()=>o});var r=t(6540);const i={},a=r.createContext(i);function s(e){const n=r.useContext(a);return r.useMemo((function(){return"function"==typeof e?e(n):{...n,...e}}),[n,e])}function o(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(i):e.components||i:s(e.components),r.createElement(a.Provider,{value:n},e.children)}}}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[647],{7121:(e,s,n)=>{n.r(s),n.d(s,{default:()=>p});n(6540);var r=n(4164),c=n(1003),u=n(7559),a=n(2831),i=n(781),d=n(4848);function p(e){return(0,d.jsx)(c.e3,{className:(0,r.A)(u.G.wrapper.docsPages),children:(0,d.jsx)(i.A,{children:(0,a.v)(e.route.routes)})})}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[863],{4702:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>l,contentTitle:()=>r,default:()=>h,frontMatter:()=>a,metadata:()=>o,toc:()=>c});var i=t(4848),s=t(8453);const a={},r="Translating",o={id:"help-out/translate",title:"Translating",description:"You can help to translate Pingvin Share into your language.",source:"@site/docs/help-out/translate.md",sourceDirName:"help-out",slug:"/help-out/translate",permalink:"/pingvin-share/help-out/translate",draft:!1,unlisted:!1,editUrl:"https://github.com/stonith404/pingvin-share/edit/main/docs/docs/help-out/translate.md",tags:[],version:"current",frontMatter:{},sidebar:"docsSidebar",previous:{title:"Upgrading",permalink:"/pingvin-share/setup/upgrading"},next:{title:"Contributing",permalink:"/pingvin-share/help-out/contribute"}},l={},c=[];function u(e){const n={a:"a",h1:"h1",header:"header",p:"p",...(0,s.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.header,{children:(0,i.jsx)(n.h1,{id:"translating",children:"Translating"})}),"\n",(0,i.jsxs)(n.p,{children:["You can help to translate Pingvin Share into your language.\nOn ",(0,i.jsx)(n.a,{href:"https://crowdin.com/project/pingvin-share",children:"Crowdin"})," you can easily translate Pingvin Share online."]}),"\n",(0,i.jsxs)(n.p,{children:["Is your language not on Crowdin? Feel free to ",(0,i.jsx)(n.a,{href:"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",children:"Request it"}),"."]}),"\n",(0,i.jsxs)(n.p,{children:["Any issues while translating? Feel free to participate in the ",(0,i.jsx)(n.a,{href:"https://github.com/stonith404/pingvin-share/discussions/198",children:"Localization discussion"}),"."]})]})}function h(e={}){const{wrapper:n}={...(0,s.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(u,{...e})}):u(e)}},8453:(e,n,t)=>{t.d(n,{R:()=>r,x:()=>o});var i=t(6540);const s={},a=i.createContext(s);function r(e){const n=i.useContext(a);return i.useMemo((function(){return"function"==typeof e?e(n):{...n,...e}}),[n,e])}function o(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(s):e.components||s:r(e.components),i.createElement(a.Provider,{value:n},e.children)}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[862],{4388:e=>{e.exports=JSON.parse('{"version":{"pluginId":"default","version":"current","label":"Next","banner":null,"badge":false,"noIndex":false,"className":"docs-version-current","isLast":true,"docsSidebars":{"docsSidebar":[{"type":"link","label":"Introduction","href":"/pingvin-share/introduction","docId":"introduction","unlisted":false},{"type":"category","label":"Getting Started","items":[{"type":"link","label":"Installation","href":"/pingvin-share/setup/installation","docId":"setup/installation","unlisted":false},{"type":"link","label":"Configuration","href":"/pingvin-share/setup/configuration","docId":"setup/configuration","unlisted":false},{"type":"link","label":"Integrations","href":"/pingvin-share/setup/integrations","docId":"setup/integrations","unlisted":false},{"type":"link","label":"OAuth 2 Login Guide","href":"/pingvin-share/setup/oauth2login","docId":"setup/oauth2login","unlisted":false},{"type":"link","label":"S3","href":"/pingvin-share/setup/s3","docId":"setup/s3","unlisted":false},{"type":"link","label":"Upgrading","href":"/pingvin-share/setup/upgrading","docId":"setup/upgrading","unlisted":false}],"collapsed":true,"collapsible":true},{"type":"category","label":"Helping Out","items":[{"type":"link","label":"Translating","href":"/pingvin-share/help-out/translate","docId":"help-out/translate","unlisted":false},{"type":"link","label":"Contributing","href":"/pingvin-share/help-out/contribute","docId":"help-out/contribute","unlisted":false}],"collapsed":true,"collapsible":true},{"type":"link","label":"Demo","href":"https://pingvin-share.dev.eliasschneider.com"},{"type":"link","label":"Discord","href":"https://discord.gg/HutpbfB59Q"}]},"docs":{"help-out/contribute":{"id":"help-out/contribute","title":"Contributing","description":"We would \u2764\ufe0f for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.","sidebar":"docsSidebar"},"help-out/translate":{"id":"help-out/translate","title":"Translating","description":"You can help to translate Pingvin Share into your language.","sidebar":"docsSidebar"},"introduction":{"id":"introduction","title":"Introduction","description":"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.","sidebar":"docsSidebar"},"setup/configuration":{"id":"setup/configuration","title":"Configuration","description":"General configuration","sidebar":"docsSidebar"},"setup/installation":{"id":"setup/installation","title":"Installation","description":"Installation with Docker (recommended)","sidebar":"docsSidebar"},"setup/integrations":{"id":"setup/integrations","title":"Integrations","description":"ClamAV","sidebar":"docsSidebar"},"setup/oauth2login":{"id":"setup/oauth2login","title":"OAuth 2 Login Guide","description":"Config Built-in OAuth 2 Providers","sidebar":"docsSidebar"},"setup/s3":{"id":"setup/s3","title":"S3","description":"You are able to add your preferred S3 provider, like AWS, DigitalOcean, Exoscale or Infomaniak. However, if you don\'t","sidebar":"docsSidebar"},"setup/upgrading":{"id":"setup/upgrading","title":"Upgrading","description":"Upgrade to a new version","sidebar":"docsSidebar"}}}}')}}]);
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[899],{9233:(n,e,i)=>{i.r(e),i.d(e,{assets:()=>c,contentTitle:()=>o,default:()=>h,frontMatter:()=>s,metadata:()=>a,toc:()=>d});var t=i(4848),r=i(8453);const s={id:"introduction"},o="Introduction",a={id:"introduction",title:"Introduction",description:"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.",source:"@site/docs/introduction.md",sourceDirName:".",slug:"/introduction",permalink:"/pingvin-share/introduction",draft:!1,unlisted:!1,editUrl:"https://github.com/stonith404/pingvin-share/edit/main/docs/docs/introduction.md",tags:[],version:"current",frontMatter:{id:"introduction"},sidebar:"docsSidebar",next:{title:"Installation",permalink:"/pingvin-share/setup/installation"}},c={},d=[{value:"Features",id:"features",level:2},{value:"Get to know Pingvin Share",id:"get-to-know-pingvin-share",level:2}];function l(n){const e={a:"a",h1:"h1",h2:"h2",header:"header",li:"li",p:"p",ul:"ul",...(0,r.R)(),...n.components};return(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(e.header,{children:(0,t.jsx)(e.h1,{id:"introduction",children:"Introduction"})}),"\n",(0,t.jsx)(e.p,{children:"Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer."}),"\n",(0,t.jsx)(e.h2,{id:"features",children:"Features"}),"\n",(0,t.jsxs)(e.ul,{children:["\n",(0,t.jsx)(e.li,{children:"Share files using a link"}),"\n",(0,t.jsx)(e.li,{children:"Unlimited file size (restricted only by disk space)"}),"\n",(0,t.jsx)(e.li,{children:"Set an expiration date for shares"}),"\n",(0,t.jsx)(e.li,{children:"Secure shares with visitor limits and passwords"}),"\n",(0,t.jsx)(e.li,{children:"Email recipients"}),"\n",(0,t.jsx)(e.li,{children:"Integration with ClamAV for security scans"}),"\n"]}),"\n",(0,t.jsx)(e.p,{children:"And more!"}),"\n",(0,t.jsx)(e.h2,{id:"get-to-know-pingvin-share",children:"Get to know Pingvin Share"}),"\n",(0,t.jsxs)(e.ul,{children:["\n",(0,t.jsx)(e.li,{children:(0,t.jsx)(e.a,{href:"https://pingvin-share.dev.eliasschneider.com",children:"Demo"})}),"\n",(0,t.jsx)(e.li,{children:(0,t.jsx)(e.a,{href:"https://www.youtube.com/watch?v=rWwNeZCOPJA",children:"Review by DB Tech"})}),"\n"]}),"\n",(0,t.jsx)("img",{src:"https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png",width:"700"})]})}function h(n={}){const{wrapper:e}={...(0,r.R)(),...n.components};return e?(0,t.jsx)(e,{...n,children:(0,t.jsx)(l,{...n})}):l(n)}},8453:(n,e,i)=>{i.d(e,{R:()=>o,x:()=>a});var t=i(6540);const r={},s=t.createContext(r);function o(n){const e=t.useContext(s);return t.useMemo((function(){return"function"==typeof n?n(e):{...e,...n}}),[e,n])}function a(n){let e;return e=n.disableParentContext?"function"==typeof n.components?n.components(r):n.components||r:o(n.components),t.createElement(s.Provider,{value:e},n.children)}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[537],{7744:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>l,contentTitle:()=>r,default:()=>h,frontMatter:()=>a,metadata:()=>o,toc:()=>c});var i=t(4848),s=t(8453);const a={id:"integrations"},r="Integrations",o={id:"setup/integrations",title:"Integrations",description:"ClamAV",source:"@site/docs/setup/integrations.md",sourceDirName:"setup",slug:"/setup/integrations",permalink:"/pingvin-share/setup/integrations",draft:!1,unlisted:!1,editUrl:"https://github.com/stonith404/pingvin-share/edit/main/docs/docs/setup/integrations.md",tags:[],version:"current",frontMatter:{id:"integrations"},sidebar:"docsSidebar",previous:{title:"Configuration",permalink:"/pingvin-share/setup/configuration"},next:{title:"OAuth 2 Login Guide",permalink:"/pingvin-share/setup/oauth2login"}},l={},c=[{value:"ClamAV",id:"clamav",level:2},{value:"Docker",id:"docker",level:3},{value:"Stand-Alone",id:"stand-alone",level:3}];function d(e){const n={a:"a",code:"code",h1:"h1",h2:"h2",h3:"h3",header:"header",li:"li",ol:"ol",p:"p",pre:"pre",...(0,s.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.header,{children:(0,i.jsx)(n.h1,{id:"integrations",children:"Integrations"})}),"\n",(0,i.jsx)(n.h2,{id:"clamav",children:"ClamAV"}),"\n",(0,i.jsx)(n.p,{children:"ClamAV is used to scan shares for malicious files and remove them if found."}),"\n",(0,i.jsxs)(n.p,{children:["Please note that ClamAV needs a lot of ",(0,i.jsx)(n.a,{href:"https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements",children:"ressources"}),"."]}),"\n",(0,i.jsx)(n.h3,{id:"docker",children:"Docker"}),"\n",(0,i.jsxs)(n.p,{children:["If you are already running ClamAV elsewhere, you can specify the ",(0,i.jsx)(n.code,{children:"CLAMAV_HOST"})," environment variable to point to that instance."]}),"\n",(0,i.jsx)(n.p,{children:"Else you have to add the ClamAV container to the Pingvin Share Docker Compose stack:"}),"\n",(0,i.jsxs)(n.ol,{children:["\n",(0,i.jsx)(n.li,{children:"Add the ClamAV container to the Docker Compose stack and start the container."}),"\n"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-diff",children:"services:\n pingvin-share:\n image: stonith404/pingvin-share\n ...\n+ depends_on:\n+ clamav:\n+ condition: service_healthy\n\n+ clamav:\n+ restart: unless-stopped\n+ image: clamav/clamav\n\n"})}),"\n",(0,i.jsxs)(n.ol,{start:"2",children:["\n",(0,i.jsx)(n.li,{children:"Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two."}),"\n",(0,i.jsx)(n.li,{children:'The Pingvin Share logs should now log "ClamAV is active"'}),"\n"]}),"\n",(0,i.jsx)(n.h3,{id:"stand-alone",children:"Stand-Alone"}),"\n",(0,i.jsxs)(n.ol,{children:["\n",(0,i.jsx)(n.li,{children:"Install ClamAV"}),"\n",(0,i.jsxs)(n.li,{children:["Specify the ",(0,i.jsx)(n.code,{children:"CLAMAV_HOST"})," environment variable for the backend and restart the Pingvin Share backend."]}),"\n"]})]})}function h(e={}){const{wrapper:n}={...(0,s.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(d,{...e})}):d(e)}},8453:(e,n,t)=>{t.d(n,{R:()=>r,x:()=>o});var i=t(6540);const s={},a=i.createContext(s);function r(e){const n=i.useContext(a);return i.useMemo((function(){return"function"==typeof e?e(n):{...n,...e}}),[n,e])}function o(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(s):e.components||s:r(e.components),i.createElement(a.Provider,{value:n},e.children)}}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[235],{8552:s=>{s.exports=JSON.parse('{"name":"docusaurus-plugin-content-pages","id":"default"}')}}]);
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[98],{1723:(n,e,s)=>{s.r(e),s.d(e,{default:()=>l});s(6540);var r=s(1003);function o(n,e){return`docs-${n}-${e}`}var i=s(3025),t=s(2831),c=s(1463),u=s(4848);function a(n){const{version:e}=n;return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(c.A,{version:e.version,tag:o(e.pluginId,e.version)}),(0,u.jsx)(r.be,{children:e.noIndex&&(0,u.jsx)("meta",{name:"robots",content:"noindex, nofollow"})})]})}function d(n){const{version:e,route:s}=n;return(0,u.jsx)(r.e3,{className:e.className,children:(0,u.jsx)(i.n,{version:e,children:(0,t.v)(s.routes)})})}function l(n){return(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(a,{...n}),(0,u.jsx)(d,{...n})]})}}}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
"use strict";(self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[]).push([[742],{7093:s=>{s.exports=JSON.parse('{"name":"docusaurus-plugin-content-docs","id":"default"}')}}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,64 +0,0 @@
|
||||
/* NProgress, (c) 2013, 2014 Rico Sta. Cruz - http://ricostacruz.com/nprogress
|
||||
* @license MIT */
|
||||
|
||||
/*! Bundled license information:
|
||||
|
||||
prismjs/prism.js:
|
||||
(**
|
||||
* Prism: Lightweight, robust, elegant syntax highlighting
|
||||
*
|
||||
* @license MIT <https://opensource.org/licenses/MIT>
|
||||
* @author Lea Verou <https://lea.verou.me>
|
||||
* @namespace
|
||||
* @public
|
||||
*)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
@@ -1 +0,0 @@
|
||||
(()=>{"use strict";var e,t,r,a,o,n={},d={};function i(e){var t=d[e];if(void 0!==t)return t.exports;var r=d[e]={id:e,loaded:!1,exports:{}};return n[e].call(r.exports,r,r.exports,i),r.loaded=!0,r.exports}i.m=n,i.c=d,e=[],i.O=(t,r,a,o)=>{if(!r){var n=1/0;for(u=0;u<e.length;u++){r=e[u][0],a=e[u][1],o=e[u][2];for(var d=!0,c=0;c<r.length;c++)(!1&o||n>=o)&&Object.keys(i.O).every((e=>i.O[e](r[c])))?r.splice(c--,1):(d=!1,o<n&&(n=o));if(d){e.splice(u--,1);var f=a();void 0!==f&&(t=f)}}return t}o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[r,a,o]},i.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return i.d(t,{a:t}),t},r=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__,i.t=function(e,a){if(1&a&&(e=this(e)),8&a)return e;if("object"==typeof e&&e){if(4&a&&e.__esModule)return e;if(16&a&&"function"==typeof e.then)return e}var o=Object.create(null);i.r(o);var n={};t=t||[null,r({}),r([]),r(r)];for(var d=2&a&&e;"object"==typeof d&&!~t.indexOf(d);d=r(d))Object.getOwnPropertyNames(d).forEach((t=>n[t]=()=>e[t]));return n.default=()=>e,i.d(o,n),o},i.d=(e,t)=>{for(var r in t)i.o(t,r)&&!i.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce(((t,r)=>(i.f[r](e,t),t)),[])),i.u=e=>"assets/js/"+({48:"a94703ab",98:"a7bd4aaa",99:"a8c31fc1",126:"d131c913",235:"a7456010",361:"3205bd6d",401:"17896441",537:"a0d20388",583:"1df93b7f",647:"5e95c892",695:"ec436908",723:"91b3cb8d",742:"aba21aa0",819:"3f2e6990",862:"7cbc9cee",863:"63b14240",899:"a09c2993"}[e]||e)+"."+{48:"78fc18a9",98:"3d51fbb4",99:"c26fc8a4",126:"edef1693",235:"91f0b3e9",237:"f947e7e3",361:"3f885d6f",401:"402e11f1",537:"9147194f",583:"2d1029fa",647:"59168106",695:"008b0520",723:"073a48e1",742:"ad2b3c84",819:"6d5c25a8",862:"4664cf81",863:"e94c1db4",899:"e0cc7812"}[e]+".js",i.miniCssF=e=>{},i.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),a={},o="pingvindocs:",i.l=(e,t,r,n)=>{if(a[e])a[e].push(t);else{var d,c;if(void 0!==r)for(var f=document.getElementsByTagName("script"),u=0;u<f.length;u++){var l=f[u];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==o+r){d=l;break}}d||(c=!0,(d=document.createElement("script")).charset="utf-8",d.timeout=120,i.nc&&d.setAttribute("nonce",i.nc),d.setAttribute("data-webpack",o+r),d.src=e),a[e]=[t];var b=(t,r)=>{d.onerror=d.onload=null,clearTimeout(s);var o=a[e];if(delete a[e],d.parentNode&&d.parentNode.removeChild(d),o&&o.forEach((e=>e(r))),t)return t(r)},s=setTimeout(b.bind(null,void 0,{type:"timeout",target:d}),12e4);d.onerror=b.bind(null,d.onerror),d.onload=b.bind(null,d.onload),c&&document.head.appendChild(d)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.p="/pingvin-share/",i.gca=function(e){return e={17896441:"401",a94703ab:"48",a7bd4aaa:"98",a8c31fc1:"99",d131c913:"126",a7456010:"235","3205bd6d":"361",a0d20388:"537","1df93b7f":"583","5e95c892":"647",ec436908:"695","91b3cb8d":"723",aba21aa0:"742","3f2e6990":"819","7cbc9cee":"862","63b14240":"863",a09c2993:"899"}[e]||e,i.p+i.u(e)},(()=>{var e={354:0,869:0};i.f.j=(t,r)=>{var a=i.o(e,t)?e[t]:void 0;if(0!==a)if(a)r.push(a[2]);else if(/^(354|869)$/.test(t))e[t]=0;else{var o=new Promise(((r,o)=>a=e[t]=[r,o]));r.push(a[2]=o);var n=i.p+i.u(t),d=new Error;i.l(n,(r=>{if(i.o(e,t)&&(0!==(a=e[t])&&(e[t]=void 0),a)){var o=r&&("load"===r.type?"missing":r.type),n=r&&r.target&&r.target.src;d.message="Loading chunk "+t+" failed.\n("+o+": "+n+")",d.name="ChunkLoadError",d.type=o,d.request=n,a[1](d)}}),"chunk-"+t,t)}},i.O.j=t=>0===e[t];var t=(t,r)=>{var a,o,n=r[0],d=r[1],c=r[2],f=0;if(n.some((t=>0!==e[t]))){for(a in d)i.o(d,a)&&(i.m[a]=d[a]);if(c)var u=c(i)}for(t&&t(r);f<n.length;f++)o=n[f],i.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return i.O(u)},r=self.webpackChunkpingvindocs=self.webpackChunkpingvindocs||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})()})();
|
||||
6
backend/.eslintrc.json
Normal file
6
backend/.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"root": true
|
||||
}
|
||||
1
backend/.prettierignore
Normal file
1
backend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/src/constants.ts
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger"]
|
||||
}
|
||||
}
|
||||
10328
backend/package-lock.json
generated
Normal file
10328
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
backend/package.json
Normal file
89
backend/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "1.10.4",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"format": "prettier --end-of-line=auto --write 'src/**/*.ts'",
|
||||
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.4.3",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.3",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.3",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.41.1",
|
||||
"body-parser": "^1.20.3",
|
||||
"cache-manager": "^5.7.6",
|
||||
"clamscan": "^2.3.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"jmespath": "^0.16.0",
|
||||
"ldapts": "^7.2.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.15",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.3",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/clamscan": "^2.0.8",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cron": "^2.4.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode-svg": "^1.1.5",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.6.0",
|
||||
"@typescript-eslint/parser": "^8.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"newman": "^6.2.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prisma": "^6.4.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.6.2",
|
||||
"wait-on": "^8.0.1"
|
||||
}
|
||||
}
|
||||
2
backend/prisma/.env
Normal file
2
backend/prisma/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
#This file is only used to set a default value for the database url
|
||||
DATABASE_URL="file:../data/pingvin-share.db"
|
||||
57
backend/prisma/migrations/20221011172612_init/migration.sql
Normal file
57
backend/prisma/migrations/20221011172612_init/migration.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"firstName" TEXT,
|
||||
"lastName" TEXT
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT NOT NULL,
|
||||
"size" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
CONSTRAINT "File_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareSecurity" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"password" TEXT,
|
||||
"maxViews" INTEGER,
|
||||
"shareId" TEXT,
|
||||
CONSTRAINT "ShareSecurity_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ShareSecurity_shareId_key" ON "ShareSecurity"("shareId");
|
||||
@@ -0,0 +1,14 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_RefreshToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "token", "userId") SELECT "createdAt", "expiresAt", "token", "userId" FROM "RefreshToken";
|
||||
DROP TABLE "RefreshToken";
|
||||
ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"creatorId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareRecipient" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
CONSTRAINT "ShareRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"creatorId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
|
||||
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "password", "updatedAt", 'user-' || User.id as "username" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_Config" ("description", "key", "locked", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Share" ADD COLUMN "description" TEXT;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoginToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT "LoginToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpSecret" TEXT
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "updatedAt", "username" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `category` to the `Config` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
|
||||
UPDATE config SET category = "internal" WHERE key = "SETUP_FINISHED";
|
||||
UPDATE config SET category = "internal" WHERE key = "TOTP_SECRET";
|
||||
UPDATE config SET category = "internal" WHERE key = "JWT_SECRET";
|
||||
UPDATE config SET category = "general" WHERE key = "APP_URL";
|
||||
UPDATE config SET category = "general" WHERE key = "SHOW_HOME_PAGE";
|
||||
UPDATE config SET category = "share" WHERE key = "ALLOW_REGISTRATION";
|
||||
UPDATE config SET category = "share" WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
|
||||
UPDATE config SET category = "share" WHERE key = "MAX_FILE_SIZE";
|
||||
UPDATE config SET category = "email" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
|
||||
UPDATE config SET category = "email" WHERE key = "EMAIL_MESSAGE";
|
||||
UPDATE config SET category = "email" WHERE key = "EMAIL_SUBJECT";
|
||||
UPDATE config SET category = "email" WHERE key = "SMTP_HOST";
|
||||
UPDATE config SET category = "email" WHERE key = "SMTP_PORT";
|
||||
UPDATE config SET category = "email" WHERE key = "SMTP_EMAIL";
|
||||
UPDATE config SET category = "email" WHERE key = "SMTP_USERNAME";
|
||||
UPDATE config SET category = "email" WHERE key = "SMTP_PASSWORD";
|
||||
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
DROP TABLE "RefreshToken";
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `order` to the `Config` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReverseShare" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"token" TEXT NOT NULL,
|
||||
"shareExpiration" DATETIME NOT NULL,
|
||||
"maxShareSize" TEXT NOT NULL,
|
||||
"sendEmailNotification" BOOLEAN NOT NULL,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
"shareId" TEXT,
|
||||
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ReverseShare_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Config" ("category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "order") SELECT "category", "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", 0 FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ReverseShare_shareId_key" ON "ReverseShare"("shareId");
|
||||
|
||||
-- Custom migration
|
||||
UPDATE Config SET `order` = 0 WHERE key = "JWT_SECRET";
|
||||
UPDATE Config SET `order` = 0 WHERE key = "TOTP_SECRET";
|
||||
|
||||
UPDATE Config SET `order` = 1 WHERE key = "APP_URL";
|
||||
UPDATE Config SET `order` = 2 WHERE key = "SHOW_HOME_PAGE";
|
||||
UPDATE Config SET `order` = 3 WHERE key = "ALLOW_REGISTRATION";
|
||||
UPDATE Config SET `order` = 4 WHERE key = "ALLOW_UNAUTHENTICATED_SHARES";
|
||||
UPDATE Config SET `order` = 5 WHERE key = "MAX_SHARE_SIZE";
|
||||
UPDATE Config SET `order` = 6, key = "ENABLE_SHARE_EMAIL_RECIPIENTS" WHERE key = "ENABLE_EMAIL_RECIPIENTS";
|
||||
UPDATE Config SET `order` = 7, key = "SHARE_RECEPIENTS_EMAIL_MESSAGE" WHERE key = "EMAIL_MESSAGE";
|
||||
UPDATE Config SET `order` = 8, key = "SHARE_RECEPIENTS_EMAIL_SUBJECT" WHERE key = "EMAIL_SUBJECT";
|
||||
UPDATE Config SET `order` = 12 WHERE key = "SMTP_HOST";
|
||||
UPDATE Config SET `order` = 13 WHERE key = "SMTP_PORT";
|
||||
UPDATE Config SET `order` = 14 WHERE key = "SMTP_EMAIL";
|
||||
UPDATE Config SET `order` = 15 WHERE key = "SMTP_USERNAME";
|
||||
UPDATE Config SET `order` = 16 WHERE key = "SMTP_PASSWORD";
|
||||
|
||||
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`) VALUES (11, "SMTP_ENABLED", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "boolean", IFNULL((SELECT value FROM Config WHERE key="ENABLE_SHARE_EMAIL_RECIPIENTS"), "false"), "smtp", 0, strftime('%s', 'now'));
|
||||
INSERT INTO Config (`order`, `key`, `description`, `type`, `value`, `category`, `secret`, `updatedAt`, `locked`) VALUES (0, "SETUP_STATUS", "Status of the setup wizard", "string", IIF((SELECT value FROM Config WHERE key="SETUP_FINISHED") == "true", "FINISHED", "STARTED"), "internal", 0, strftime('%s', 'now'), 1);
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
|
||||
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "ResetPasswordToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Disable TOTP as secret isn't encrypted anymore
|
||||
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
|
||||
|
||||
-- RedefineTables
|
||||
CREATE TABLE "new_Share" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiration" DATETIME NOT NULL,
|
||||
"description" TEXT,
|
||||
"removedReason" TEXT,
|
||||
"creatorId" TEXT,
|
||||
"reverseShareId" TEXT,
|
||||
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
|
||||
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
|
||||
FROM "Share";
|
||||
|
||||
|
||||
DROP TABLE "Share";
|
||||
ALTER TABLE "new_Share" RENAME TO "Share";
|
||||
CREATE TABLE "new_ReverseShare" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"token" TEXT NOT NULL,
|
||||
"shareExpiration" DATETIME NOT NULL,
|
||||
"maxShareSize" TEXT NOT NULL,
|
||||
"sendEmailNotification" BOOLEAN NOT NULL,
|
||||
"remainingUses" INTEGER NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) FROM "ReverseShare";
|
||||
DROP TABLE "ReverseShare";
|
||||
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
|
||||
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
|
||||
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("name", "category")
|
||||
);
|
||||
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
|
||||
|
||||
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT,
|
||||
"defaultValue" TEXT NOT NULL DEFAULT '',
|
||||
"description" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("name", "category")
|
||||
);
|
||||
INSERT INTO "new_Config" ("category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `description` on the `Config` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"defaultValue" TEXT NOT NULL DEFAULT '',
|
||||
"value" TEXT,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("name", "category")
|
||||
);
|
||||
INSERT INTO "new_Config" ("category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
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";
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
163
backend/prisma/schema.prisma
Normal file
163
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,163 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String?
|
||||
isAdmin Boolean @default(false)
|
||||
ldapDN String? @unique
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
|
||||
oAuthUsers OAuthUser[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
token String @unique @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
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 {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
used Boolean @default(false)
|
||||
}
|
||||
|
||||
model ResetPasswordToken {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model 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)
|
||||
expiration DateTime
|
||||
description String?
|
||||
removedReason String?
|
||||
|
||||
creatorId String?
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
reverseShareId String?
|
||||
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
|
||||
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
storageProvider String @default("LOCAL")
|
||||
}
|
||||
|
||||
model ReverseShare {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
token String @unique @default(uuid())
|
||||
shareExpiration DateTime
|
||||
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)
|
||||
|
||||
shares Share[]
|
||||
}
|
||||
|
||||
model ShareRecipient {
|
||||
id String @id @default(uuid())
|
||||
email String
|
||||
|
||||
shareId String
|
||||
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
name String
|
||||
size String
|
||||
|
||||
shareId String
|
||||
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ShareSecurity {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
password String?
|
||||
maxViews Int?
|
||||
|
||||
shareId String? @unique
|
||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
|
||||
@@id([name, category])
|
||||
}
|
||||
499
backend/prisma/seed/config.seed.ts
Normal file
499
backend/prisma/seed/config.seed.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
export const configVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
general: {
|
||||
appName: {
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share",
|
||||
secret: false,
|
||||
},
|
||||
appUrl: {
|
||||
type: "string",
|
||||
defaultValue: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
secureCookies: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
showHomePage: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
sessionDuration: {
|
||||
type: "timespan",
|
||||
defaultValue: "3 months",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
share: {
|
||||
allowRegistration: {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthenticatedShares: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
maxExpiration: {
|
||||
type: "timespan",
|
||||
defaultValue: "0 days",
|
||||
secret: false,
|
||||
},
|
||||
shareIdLength: {
|
||||
type: "number",
|
||||
defaultValue: "8",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
type: "filesize",
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
zipCompressionLevel: {
|
||||
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: {
|
||||
type: "string",
|
||||
defaultValue: "Files shared with you",
|
||||
},
|
||||
shareRecipientsMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\n{creator} ({creatorEmail}) shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
reverseShareSubject: {
|
||||
type: "string",
|
||||
defaultValue: "Reverse share link used",
|
||||
},
|
||||
reverseShareMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
resetPasswordSubject: {
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share password reset",
|
||||
},
|
||||
resetPasswordMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share invite",
|
||||
},
|
||||
inviteMessage: {
|
||||
type: "text",
|
||||
defaultValue:
|
||||
'Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYou can use the email "{email}" and the password "{password}" to sign in.\n\nPingvin Share 🐧',
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthorizedCertificates: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
host: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
port: {
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
legal: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
imprintText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
imprintUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyText: {
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
privacyPolicyUrl: {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
} satisfies ConfigVariables;
|
||||
|
||||
export type YamlConfig = {
|
||||
[Category in keyof typeof configVariables]: {
|
||||
[Key in keyof (typeof configVariables)[Category]]: string;
|
||||
};
|
||||
} & {
|
||||
initUser: {
|
||||
enabled: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
isAdmin: boolean;
|
||||
ldapDN: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfigVariables = {
|
||||
[category: string]: {
|
||||
[variable: string]: Omit<
|
||||
Prisma.ConfigCreateInput,
|
||||
"name" | "category" | "order"
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
"file:../data/pingvin-share.db?connection_limit=1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function seedConfigVariables() {
|
||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||
configVariables
|
||||
)) {
|
||||
let order = 0;
|
||||
for (const [name, properties] of Object.entries(
|
||||
configVariablesOfCategory
|
||||
)) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { name_category: { name, category } },
|
||||
});
|
||||
|
||||
// Create a new config variable if it doesn't exist
|
||||
if (!existingConfigVariable) {
|
||||
await prisma.config.create({
|
||||
data: {
|
||||
order,
|
||||
name,
|
||||
...properties,
|
||||
category,
|
||||
},
|
||||
});
|
||||
}
|
||||
order++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateConfigVariables() {
|
||||
const existingConfigVariables = await prisma.config.findMany();
|
||||
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: {
|
||||
name_category: {
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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: {
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...configVariable,
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
order: variableOrder,
|
||||
},
|
||||
});
|
||||
orderMap[existingConfigVariable.category] = variableOrder + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seedConfigVariables()
|
||||
.then(() => migrateConfigVariables())
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(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";
|
||||
}
|
||||
}
|
||||
}
|
||||
53
backend/src/app.module.ts
Normal file
53
backend/src/app.module.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
ShareModule,
|
||||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule,
|
||||
JobsModule,
|
||||
UserModule,
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
ScheduleModule.forRoot(),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
214
backend/src/auth/auth.controller.ts
Normal file
214
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { GetUser } from "./decorator/getUser.decorator";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||
import { TokenDTO } from "./dto/token.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||
import { JwtGuard } from "./guard/jwt.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private authTotpService: AuthTotpService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post("signUp")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@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, ip);
|
||||
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("signIn")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
@Req() { ip }: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authService.signIn(dto, ip);
|
||||
|
||||
if (result.accessToken && result.refreshToken) {
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post("signIn/totp")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authTotpService.signInTotp(dto);
|
||||
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(202)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 5 * 60,
|
||||
},
|
||||
})
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() dto: UpdatePasswordDTO,
|
||||
) {
|
||||
const result = await this.authService.updatePassword(
|
||||
user,
|
||||
dto.password,
|
||||
dto.oldPassword,
|
||||
);
|
||||
|
||||
this.authService.addTokensToResponse(response, result.refreshToken);
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("token")
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||
|
||||
const accessToken = await this.authService.refreshAccessToken(
|
||||
request.cookies.refresh_token,
|
||||
);
|
||||
this.authService.addTokensToResponse(response, undefined, accessToken);
|
||||
return new TokenDTO().from({ accessToken });
|
||||
}
|
||||
|
||||
@Post("signOut")
|
||||
async signOut(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
|
||||
return this.authTotpService.enableTotp(user, body.password);
|
||||
}
|
||||
|
||||
@Post("totp/verify")
|
||||
@UseGuards(JwtGuard)
|
||||
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||
return this.authTotpService.verifyTotp(user, body.password, body.code);
|
||||
}
|
||||
|
||||
@Post("totp/disable")
|
||||
@UseGuards(JwtGuard)
|
||||
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||
}
|
||||
}
|
||||
25
backend/src/auth/auth.module.ts
Normal file
25
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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({
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
forwardRef(() => OAuthModule),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy, LdapService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
383
backend/src/auth/auth.service.ts
Normal file
383
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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 {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService,
|
||||
private ldapService: LdapService,
|
||||
private userService: UserSevice,
|
||||
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||
) {}
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||
|
||||
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: isAdmin ?? isFirstUser,
|
||||
},
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO, ip: string) {
|
||||
if (!dto.email && !dto.username) {
|
||||
throw new BadRequestException("Email or username is required");
|
||||
}
|
||||
|
||||
if (!this.config.get("oauth.disablePassword")) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.password && (await argon.verify(user.password, dto.password))) {
|
||||
this.logger.log(
|
||||
`Successful password login for user ${user.email} from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.get("ldap.enabled")) {
|
||||
/*
|
||||
* E-mail-like user credentials are passed as the email property
|
||||
* instead of the username. Since the username format does not matter
|
||||
* when searching for users in LDAP, we simply use the username
|
||||
* in whatever format it is provided.
|
||||
*/
|
||||
const ldapUsername = dto.username || dto.email;
|
||||
this.logger.debug(`Trying LDAP login for user ${ldapUsername}`);
|
||||
const ldapUser = await this.ldapService.authenticateUser(
|
||||
ldapUsername,
|
||||
dto.password,
|
||||
);
|
||||
if (ldapUser) {
|
||||
const user = await this.userService.findOrCreateFromLDAP(dto, ldapUser);
|
||||
this.logger.log(
|
||||
`Successful LDAP login for user ${ldapUsername} (${user.id}) from IP ${ip}`,
|
||||
);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Failed login attempt for user ${dto.email || dto.username} from IP ${ip}`,
|
||||
);
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
}
|
||||
|
||||
async generateToken(user: User, 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 && !(oauth && this.config.get("oauth.ignoreTotp"))) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id,
|
||||
oauth?.idToken,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
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) return;
|
||||
|
||||
if (user.ldapDN) {
|
||||
this.logger.log(
|
||||
`Failed password reset request for user ${email} because it is an LDAP user`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"This account can't reset its password here. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token: user.resetPasswordToken.token },
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await this.prisma.resetPasswordToken.create({
|
||||
data: {
|
||||
expiresAt: moment().add(1, "hour").toDate(),
|
||||
user: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
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 } },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||
|
||||
const newPasswordHash = await argon.hash(newPassword);
|
||||
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newPasswordHash },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, 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);
|
||||
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hash },
|
||||
});
|
||||
|
||||
return this.createRefreshToken(user.id);
|
||||
}
|
||||
|
||||
async createAccessToken(user: User, refreshTokenId: string) {
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
refreshTokenId,
|
||||
},
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async signOut(accessToken: string) {
|
||||
const { refreshTokenId } =
|
||||
(this.jwtService.decode(accessToken) as {
|
||||
refreshTokenId: string;
|
||||
}) || {};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string) {
|
||||
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
||||
throw new UnauthorizedException();
|
||||
|
||||
return this.createAccessToken(
|
||||
refreshTokenMetaData.user,
|
||||
refreshTokenMetaData.id,
|
||||
);
|
||||
}
|
||||
|
||||
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(sessionDuration.value, sessionDuration.unit)
|
||||
.toDate(),
|
||||
oauthIDToken: idToken,
|
||||
},
|
||||
});
|
||||
|
||||
return { refreshTokenId: id, refreshToken: token };
|
||||
}
|
||||
|
||||
async createLoginToken(userId: string) {
|
||||
const loginToken = (
|
||||
await this.prisma.loginToken.create({
|
||||
data: { userId, expiresAt: moment().add(5, "minutes").toDate() },
|
||||
})
|
||||
).token;
|
||||
|
||||
return 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);
|
||||
}
|
||||
}
|
||||
167
backend/src/auth/authTotp.service.ts
Normal file
167
backend/src/auth/authTotp.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
const token = await this.prisma.loginToken.findFirst({
|
||||
where: {
|
||||
token: dto.loginToken,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
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 } = token.user;
|
||||
|
||||
if (!totpSecret) {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
if (!authenticator.check(dto.totp, totpSecret)) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
}
|
||||
|
||||
// Set the login token to used
|
||||
await this.prisma.loginToken.update({
|
||||
where: { token: token.token },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } =
|
||||
await this.authService.createRefreshToken(token.user.id);
|
||||
const accessToken = await this.authService.createAccessToken(
|
||||
token.user,
|
||||
refreshTokenId,
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
// Check if we have a secret already
|
||||
const { totpVerified } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpVerified: true },
|
||||
});
|
||||
|
||||
if (totpVerified) {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
const issuer = this.configService.get("general.appName");
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(user.username || user.email, issuer, secret);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpEnabled: true,
|
||||
totpSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Maybe we should generate the QR code on the client rather than the server?
|
||||
const qrCode = new qrcode({
|
||||
content: otpURL,
|
||||
container: "svg-viewbox",
|
||||
join: true,
|
||||
}).svg();
|
||||
|
||||
return {
|
||||
totpAuthUrl: otpURL,
|
||||
totpSecret: secret,
|
||||
qrCode:
|
||||
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyTotp(user: User, password: string, code: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpSecret: true },
|
||||
});
|
||||
|
||||
if (!totpSecret) {
|
||||
throw new BadRequestException("TOTP is not in progress");
|
||||
}
|
||||
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async disableTotp(user: User, password: string, code: string) {
|
||||
if (!this.authService.verifyPassword(user, password))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpSecret: true },
|
||||
});
|
||||
|
||||
if (!totpSecret) {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpVerified: false,
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
backend/src/auth/decorator/getUser.decorator.ts
Normal file
9
backend/src/auth/decorator/getUser.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
export const GetUser = createParamDecorator(
|
||||
(data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
8
backend/src/auth/dto/authRegister.dto.ts
Normal file
8
backend/src/auth/dto/authRegister.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||
"email",
|
||||
"username",
|
||||
"password",
|
||||
] as const) {}
|
||||
14
backend/src/auth/dto/authSignIn.dto.ts
Normal file
14
backend/src/auth/dto/authSignIn.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class AuthSignInDTO {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
10
backend/src/auth/dto/authSignInTotp.dto.ts
Normal file
10
backend/src/auth/dto/authSignInTotp.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString } from "class-validator";
|
||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||
|
||||
export class AuthSignInTotpDTO {
|
||||
@IsString()
|
||||
totp: string;
|
||||
|
||||
@IsString()
|
||||
loginToken: string;
|
||||
}
|
||||
6
backend/src/auth/dto/enableTotp.dto.ts
Normal file
6
backend/src/auth/dto/enableTotp.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class EnableTotpDTO {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
15
backend/src/auth/dto/token.dto.ts
Normal file
15
backend/src/auth/dto/token.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
|
||||
export class TokenDTO {
|
||||
@Expose()
|
||||
accessToken: string;
|
||||
|
||||
@Expose()
|
||||
refreshToken: string;
|
||||
|
||||
from(partial: Partial<TokenDTO>) {
|
||||
return plainToClass(TokenDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
9
backend/src/auth/dto/updatePassword.dto.ts
Normal file
9
backend/src/auth/dto/updatePassword.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
oldPassword?: string;
|
||||
}
|
||||
8
backend/src/auth/dto/verifyTotp.dto.ts
Normal file
8
backend/src/auth/dto/verifyTotp.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsString()
|
||||
code: string;
|
||||
}
|
||||
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AdministratorGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext) {
|
||||
const { user }: { user: User } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
||||
17
backend/src/auth/guard/jwt.guard.ts
Normal file
17
backend/src/auth/guard/jwt.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard("jwt") {
|
||||
constructor(private config: ConfigService) {
|
||||
super();
|
||||
}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch {
|
||||
return this.config.get("share.allowUnauthenticatedShares");
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/src/auth/strategy/jwt.strategy.ts
Normal file
33
backend/src/auth/strategy/jwt.strategy.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
config.get("internal.jwtSecret");
|
||||
super({
|
||||
jwtFromRequest: JwtStrategy.extractJWT,
|
||||
secretOrKey: config.get("internal.jwtSecret"),
|
||||
});
|
||||
}
|
||||
|
||||
private static extractJWT(req: Request) {
|
||||
if (!req.cookies.access_token) return null;
|
||||
return req.cookies.access_token;
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string }) {
|
||||
const user: User = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
}
|
||||
10
backend/src/clamscan/clamscan.module.ts
Normal file
10
backend/src/clamscan/clamscan.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { FileModule } from "src/file/file.module";
|
||||
import { ClamScanService } from "./clamscan.service";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => FileModule)],
|
||||
providers: [ClamScanService],
|
||||
exports: [ClamScanService],
|
||||
})
|
||||
export class ClamScanModule {}
|
||||
88
backend/src/clamscan/clamscan.service.ts
Normal file
88
backend/src/clamscan/clamscan.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as NodeClam from "clamscan";
|
||||
import * as fs from "fs";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
const clamscanConfig = {
|
||||
clamdscan: {
|
||||
host: CLAMAV_HOST,
|
||||
port: CLAMAV_PORT,
|
||||
localFallback: false,
|
||||
},
|
||||
preference: "clamdscan",
|
||||
};
|
||||
@Injectable()
|
||||
export class ClamScanService {
|
||||
private readonly logger = new Logger(ClamScanService.name);
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
.init(clamscanConfig)
|
||||
.then((res) => {
|
||||
this.logger.log("ClamAV is active");
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
this.logger.log("ClamAV is not active");
|
||||
return null;
|
||||
});
|
||||
|
||||
async check(shareId: string) {
|
||||
const clamScan = await this.ClamScan;
|
||||
|
||||
if (!clamScan) return [];
|
||||
|
||||
const infectedFiles = [];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
|
||||
.filter((file) => file != "archive.zip");
|
||||
|
||||
for (const fileId of files) {
|
||||
const { isInfected } = await clamScan
|
||||
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
|
||||
.catch(() => {
|
||||
this.logger.log("ClamAV is not active");
|
||||
return { isInfected: false };
|
||||
});
|
||||
|
||||
const fileName = (
|
||||
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||
).name;
|
||||
|
||||
if (isInfected) {
|
||||
infectedFiles.push({ id: fileId, name: fileName });
|
||||
}
|
||||
}
|
||||
|
||||
return infectedFiles;
|
||||
}
|
||||
|
||||
async checkAndRemove(shareId: string) {
|
||||
const infectedFiles = await this.check(shareId);
|
||||
|
||||
if (infectedFiles.length > 0) {
|
||||
await this.fileService.deleteAllFiles(shareId);
|
||||
await this.prisma.file.deleteMany({ where: { shareId } });
|
||||
|
||||
const fileNames = infectedFiles.map((file) => file.name).join(", ");
|
||||
|
||||
await this.prisma.share.update({
|
||||
where: { id: shareId },
|
||||
data: {
|
||||
removedReason: `Your share got removed because the file(s) ${fileNames} are malicious.`,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
backend/src/config/config.controller.ts
Normal file
75
backend/src/config/config.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
FileTypeValidator,
|
||||
Get,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { SkipThrottle } from "@nestjs/throttler";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
||||
import { ConfigDTO } from "./dto/config.dto";
|
||||
import { TestEmailDTO } from "./dto/testEmail.dto";
|
||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||
import { LogoService } from "./logo.service";
|
||||
|
||||
@Controller("configs")
|
||||
export class ConfigController {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private logoService: LogoService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@SkipThrottle()
|
||||
async list() {
|
||||
return new ConfigDTO().fromList(await this.configService.list());
|
||||
}
|
||||
|
||||
@Get("admin/:category")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getByCategory(@Param("category") category: string) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.getByCategory(category),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("admin")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.updateMany(data),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("admin/testEmail")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||
await this.emailService.sendTestMail(email);
|
||||
}
|
||||
|
||||
@Post("admin/logo")
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async uploadLogo(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
return await this.logoService.create(file.buffer);
|
||||
}
|
||||
}
|
||||
25
backend/src/config/config.module.ts
Normal file
25
backend/src/config/config.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { LogoService } from "./logo.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EmailModule],
|
||||
providers: [
|
||||
{
|
||||
provide: "CONFIG_VARIABLES",
|
||||
useFactory: async (prisma: PrismaService) => {
|
||||
return await prisma.config.findMany();
|
||||
},
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
LogoService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
exports: [ConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
225
backend/src/config/config.service.ts
Normal file
225
backend/src/config/config.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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 extends EventEmitter {
|
||||
yamlConfig?: YamlConfig;
|
||||
logger = new Logger(ConfigService.name);
|
||||
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
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];
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
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 = 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 = this.configVariables.filter((c) => !c.secret);
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||
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) {
|
||||
response.push(await this.update(variable.key, variable.value));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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: {
|
||||
category: key.split(".")[0],
|
||||
name: key.split(".")[1],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!configVariable || configVariable.locked)
|
||||
throw new NotFoundException("Config variable not found");
|
||||
|
||||
if (value === "") {
|
||||
value = null;
|
||||
} else if (
|
||||
typeof value != configVariable.type &&
|
||||
typeof value == "string" &&
|
||||
configVariable.type != "text" &&
|
||||
configVariable.type != "timespan"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Config variable must be of type ${configVariable.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.validateConfigVariable(key, value);
|
||||
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: {
|
||||
name_category: {
|
||||
category: key.split(".")[0],
|
||||
name: key.split(".")[1],
|
||||
},
|
||||
},
|
||||
data: { value: value === null ? null : value.toString() },
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
34
backend/src/config/dto/adminConfig.dto.ts
Normal file
34
backend/src/config/dto/adminConfig.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ConfigDTO } from "./config.dto";
|
||||
|
||||
export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
secret: boolean;
|
||||
|
||||
@Expose()
|
||||
defaultValue: string;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
|
||||
@Expose()
|
||||
allowEdit: boolean;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
backend/src/config/dto/config.dto.ts
Normal file
18
backend/src/config/dto/config.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
|
||||
export class ConfigDTO {
|
||||
@Expose()
|
||||
key: string;
|
||||
|
||||
@Expose()
|
||||
value: string;
|
||||
|
||||
@Expose()
|
||||
type: string;
|
||||
|
||||
fromList(partial: Partial<ConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
backend/src/config/dto/testEmail.dto.ts
Normal file
7
backend/src/config/dto/testEmail.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsEmail, IsNotEmpty } from "class-validator";
|
||||
|
||||
export class TestEmailDTO {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
}
|
||||
11
backend/src/config/dto/updateConfig.dto.ts
Normal file
11
backend/src/config/dto/updateConfig.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
class UpdateConfigDTO {
|
||||
@IsString()
|
||||
key: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
export default UpdateConfigDTO;
|
||||
33
backend/src/config/logo.service.ts
Normal file
33
backend/src/config/logo.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as fs from "fs";
|
||||
import * as sharp from "sharp";
|
||||
|
||||
const IMAGES_PATH = "../frontend/public/img";
|
||||
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
async create(file: Buffer) {
|
||||
const resized = await sharp(file).resize(900).toBuffer();
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
||||
this.createFavicon(file);
|
||||
this.createPWAIcons(file);
|
||||
}
|
||||
|
||||
async createFavicon(file: Buffer) {
|
||||
const resized = await sharp(file).resize(16).toBuffer();
|
||||
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
|
||||
}
|
||||
|
||||
async createPWAIcons(file: Buffer) {
|
||||
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
|
||||
|
||||
for (const size of sizes) {
|
||||
const resized = await sharp(file).resize(size).toBuffer();
|
||||
fs.promises.writeFile(
|
||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||
resized,
|
||||
"binary",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user