Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d81b2220 | ||
|
|
0317f3a508 | ||
|
|
fddad3ef70 | ||
|
|
f9840505b8 | ||
|
|
759c55f625 | ||
|
|
edb511252f | ||
|
|
c3af0fe097 | ||
|
|
6419da07fb | ||
|
|
7cd9dff637 | ||
|
|
2a826f7941 | ||
|
|
8720232755 | ||
|
|
dc8cf3d5ca | ||
|
|
979b882150 | ||
|
|
c55019f71b | ||
|
|
4c6ef52a17 | ||
|
|
b9662701c4 | ||
|
|
e3f88d0826 | ||
|
|
86a7379519 | ||
|
|
ccdf8ea3ae | ||
|
|
edc10b72b7 | ||
|
|
5d1a7f0310 | ||
|
|
8ab359b71d | ||
|
|
38de022215 | ||
|
|
82f204e8a9 | ||
|
|
4e840ecd29 | ||
|
|
064ef38d78 | ||
|
|
b14e931d8d | ||
|
|
3d5c919110 | ||
|
|
008df06b5c | ||
|
|
cd9d828686 | ||
|
|
233c26e5cf | ||
|
|
91a6b3f716 | ||
|
|
0a2b7b1243 |
17
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: ❓ Question
|
||||||
|
description: "Submit a question"
|
||||||
|
title: "❓ Question:"
|
||||||
|
labels: [question]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "🙋♂️ Question"
|
||||||
|
description: "A clear question. Please provide as much detail as possible."
|
||||||
|
placeholder: "How do I ...?"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Before submitting, please check if the question hasn't been asked before.
|
||||||
2
.gitignore
vendored
@@ -39,4 +39,4 @@ yarn-error.log*
|
|||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Jetbrains specific (webstorm)
|
# Jetbrains specific (webstorm)
|
||||||
.idea/**/**
|
.idea/**/**
|
||||||
|
|||||||
63
CHANGELOG.md
@@ -1,3 +1,66 @@
|
|||||||
|
## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom branding ([#112](https://github.com/stonith404/pingvin-share/issues/112)) ([fddad3e](https://github.com/stonith404/pingvin-share/commit/fddad3ef708c27052a8bf46f3076286d102f6d7e))
|
||||||
|
* invite new user with email ([f984050](https://github.com/stonith404/pingvin-share/commit/f9840505b82fcb04364a79576f186b76cc75f5c0))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* frontend error when user deleted ([0317f3a](https://github.com/stonith404/pingvin-share/commit/0317f3a508dc88ffe2c33413704f7df03a2372ea))
|
||||||
|
|
||||||
|
### [0.10.2](https://github.com/stonith404/pingvin-share/compare/v0.10.1...v0.10.2) (2023-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pdf preview tries to render on server ([c3af0fe](https://github.com/stonith404/pingvin-share/commit/c3af0fe097582f69b63ed1ad18fb71bff334d32a))
|
||||||
|
|
||||||
|
### [0.10.1](https://github.com/stonith404/pingvin-share/compare/v0.10.0...v0.10.1) (2023-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* non administrator user redirection error while setup isn't finished ([dc8cf3d](https://github.com/stonith404/pingvin-share/commit/dc8cf3d5ca6b4f8a8f243b8e0b05e09738cf8b61))
|
||||||
|
* setup wizard doesn't redirect after completion ([7cd9dff](https://github.com/stonith404/pingvin-share/commit/7cd9dff637900098c9f6e46ccade37283d47321b))
|
||||||
|
|
||||||
|
## [0.10.0](https://github.com/stonith404/pingvin-share/compare/v0.9.0...v0.10.0) (2023-02-10)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* reset password with email
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* allow multiple shares with one reverse share link ([ccdf8ea](https://github.com/stonith404/pingvin-share/commit/ccdf8ea3ae1e7b8520c5b1dd9bea18b1b3305f35))
|
||||||
|
* **frontend:** server side rendering to improve performance ([38de022](https://github.com/stonith404/pingvin-share/commit/38de022215a9b99c2eb36654f8dbb1e17ca87aba))
|
||||||
|
* reset password with email ([5d1a7f0](https://github.com/stonith404/pingvin-share/commit/5d1a7f0310df2643213affd2a0d1785b7e0af398))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* delete all shares of reverse share ([86a7379](https://github.com/stonith404/pingvin-share/commit/86a737951951c911abd7967d76cb253c4335cb0c))
|
||||||
|
* invalid redirection after jwt expiry ([82f204e](https://github.com/stonith404/pingvin-share/commit/82f204e8a93e3113dcf65b1881d4943a898602eb))
|
||||||
|
* setup status doesn't change ([064ef38](https://github.com/stonith404/pingvin-share/commit/064ef38d783b3f351535c2911eb451efd9526d71))
|
||||||
|
* share creation without reverseShareToken ([b966270](https://github.com/stonith404/pingvin-share/commit/b9662701c42fe6771c07acb869564031accb2932))
|
||||||
|
* share fails if a share was created with a reverse share link recently ([edc10b7](https://github.com/stonith404/pingvin-share/commit/edc10b72b7884c629a8417c3c82222b135ef7653))
|
||||||
|
|
||||||
|
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* direct file link ([008df06](https://github.com/stonith404/pingvin-share/commit/008df06b5cf48872d4dd68df813370596a4fd468))
|
||||||
|
* file preview ([91a6b3f](https://github.com/stonith404/pingvin-share/commit/91a6b3f716d37d7831e17a7be1cdb35cb23da705))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve send test email UX ([233c26e](https://github.com/stonith404/pingvin-share/commit/233c26e5cfde59e7d51023ef9901dec2b84a4845))
|
||||||
|
|
||||||
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
|
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,62 +8,55 @@ You've found a bug, have suggestion or something else, just create an issue on G
|
|||||||
|
|
||||||
## Submit a Pull Request
|
## Submit a Pull Request
|
||||||
|
|
||||||
Once you created a issue and you want to create a pull request, follow this guide.
|
Before you submit the pull request for review please ensure that
|
||||||
|
|
||||||
Branch naming convention is as following
|
- The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||||
|
|
||||||
`TYPE-ISSUE_ID-DESCRIPTION`
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
example:
|
example:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): add password protection
|
||||||
|
```
|
||||||
|
|
||||||
|
When `TYPE` can be:
|
||||||
|
|
||||||
|
- **feat** - is a new feature
|
||||||
|
- **doc** - documentation only changes
|
||||||
|
- **fix** - a bug fix
|
||||||
|
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||||
|
|
||||||
|
- Your pull request has a detailed description
|
||||||
|
- You run `npm run format` to format the code
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Don't know how to create a pull request? Learn how to create a pull request</summary>
|
||||||
|
|
||||||
|
1. Create a fork of the repository by clicking on the `Fork` button in the Pingvin Share repository
|
||||||
|
|
||||||
|
2. Clone your fork to your machine with `git clone`
|
||||||
|
|
||||||
```
|
```
|
||||||
feat-69-ability-to-set-share-expiration-to-never
|
$ git clone https://github.com/[your_username]/pingvin-share
|
||||||
```
|
|
||||||
|
|
||||||
When `TYPE` can be:
|
|
||||||
|
|
||||||
- **feat** - is a new feature
|
|
||||||
- **doc** - documentation only changes
|
|
||||||
- **fix** - a bug fix
|
|
||||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
|
||||||
|
|
||||||
**All PRs must include a commit message with the changes description!**
|
|
||||||
|
|
||||||
For the initial start, fork the project and use the `git clone` command to download the repository to your computer. A standard procedure for working on an issue would be to:
|
|
||||||
|
|
||||||
1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git pull
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create new branch from `main` like: `feat-69-ability-to-set-share-expiration-to-never`<br/>
|
|
||||||
|
|
||||||
```
|
|
||||||
$ git checkout -b [name_of_your_new_branch]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Work - commit - repeat
|
3. Work - commit - repeat
|
||||||
|
|
||||||
4. Before you push your changes, make sure you run the linter and format the code.
|
4. Push changes to GitHub
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
npm run format
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Push changes to GitHub
|
|
||||||
|
|
||||||
```
|
```
|
||||||
$ git push origin [name_of_your_new_branch]
|
$ git push origin [name_of_your_new_branch]
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Submit your changes for review
|
5. Submit your changes for review
|
||||||
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button.
|
||||||
7. Start a Pull Request
|
6. Start a Pull Request
|
||||||
Now submit the pull request and click on `Create pull request`.
|
7. Now submit the pull request and click on `Create pull request`.
|
||||||
8. Get a code review approval/reject
|
8. Get a code review approval/reject
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Setup project
|
## Setup project
|
||||||
|
|
||||||
Pingvin Share consists of a frontend and a backend.
|
Pingvin Share consists of a frontend and a backend.
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ RUN apt-get update && apt-get install -y openssl
|
|||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
# Automatically leverage output traces to reduce image size
|
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
|
||||||
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
COPY --from=frontend-builder /opt/app/.next/standalone ./
|
||||||
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
COPY --from=frontend-builder /opt/app/.next/static ./.next/static
|
||||||
|
COPY --from=frontend-builder /opt/app/public/img /tmp/img
|
||||||
|
|
||||||
WORKDIR /opt/app/backend
|
WORKDIR /opt/app/backend
|
||||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||||
@@ -48,4 +47,4 @@ COPY --from=backend-builder /opt/app/package.json ./
|
|||||||
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD node frontend/server.js & cd backend && npm run prod
|
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod
|
||||||
84
README.md
@@ -4,12 +4,12 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- Create a share with files that you can access with a link
|
- Share files using a link
|
||||||
- No file size limit, only your disk will be your limit
|
- Unlimited file size (restricted only by disk space)
|
||||||
- Set a share expiration
|
- Set an expiration date for shares
|
||||||
- Optionally secure your share with a visitor limit and a password
|
- Secure shares with visitor limits and passwords
|
||||||
- Email recepients
|
- Email recipients
|
||||||
- ClamAV integration
|
- Integration with ClamAV for security scans
|
||||||
|
|
||||||
## 🐧 Get to know Pingvin Share
|
## 🐧 Get to know Pingvin Share
|
||||||
|
|
||||||
@@ -20,20 +20,50 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
## ⌨️ Setup
|
## ⌨️ Setup
|
||||||
|
|
||||||
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
> Note: Pingvin Share is in its early stages and may contain bugs.
|
||||||
|
|
||||||
### Recommended installation
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
1. Download the `docker-compose.yml` file
|
1. Download the `docker-compose.yml` file
|
||||||
2. Run `docker-compose up -d`
|
2. Run `docker-compose up -d`
|
||||||
|
|
||||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Stand-alone Installation
|
||||||
|
|
||||||
|
Required tools:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 14
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
#### ClamAV
|
#### ClamAV (Docker only)
|
||||||
|
|
||||||
With ClamAV the shares get scanned for malicious files and get removed if any found.
|
ClamAV is used to scan shares for malicious files and remove them if found.
|
||||||
|
|
||||||
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||||
@@ -47,7 +77,37 @@ Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manu
|
|||||||
|
|
||||||
### Upgrade to a new version
|
### Upgrade to a new version
|
||||||
|
|
||||||
Run `docker compose pull && docker compose up -d` to update your docker container
|
As Pingvin Share is in early stage, see the release notes for breaking changes before upgrading.
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stand-alone
|
||||||
|
|
||||||
|
1. Remove the running app
|
||||||
|
```
|
||||||
|
pm2 delete pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||||
|
|
||||||
|
### Custom branding
|
||||||
|
|
||||||
|
#### Name
|
||||||
|
|
||||||
|
You can change the name of the app by visiting the admin configuration page and changing the `App Name`.
|
||||||
|
|
||||||
|
#### Logo
|
||||||
|
|
||||||
|
You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images:
|
||||||
|
|
||||||
|
- `logo.png` - The logo in the header and home page
|
||||||
|
- `favicon.png` - The favicon
|
||||||
|
- `opengraph.png` - The image used for sharing on social media
|
||||||
|
- `icons/*` - The icons used for the PWA
|
||||||
|
|
||||||
## 🖤 Contribute
|
## 🖤 Contribute
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src"
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": ["@nestjs/swagger"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
backend/package-lock.json
generated
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
"@nestjs/core": "^9.2.1",
|
"@nestjs/core": "^9.2.1",
|
||||||
"@nestjs/jwt": "^10.0.1",
|
"@nestjs/jwt": "^10.0.1",
|
||||||
"@nestjs/mapped-types": "^1.2.0",
|
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/passport": "^9.0.0",
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
"@nestjs/platform-express": "^9.2.1",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
|
"@nestjs/swagger": "^6.2.1",
|
||||||
"@nestjs/throttler": "^3.1.0",
|
"@nestjs/throttler": "^3.1.0",
|
||||||
"@prisma/client": "^4.8.1",
|
"@prisma/client": "^4.8.1",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -704,13 +704,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/mapped-types": {
|
"node_modules/@nestjs/mapped-types": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.2.tgz",
|
||||||
"integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==",
|
"integrity": "sha512-3dHxLXs3M0GPiriAcCFFJQHoDFUuzTD5w6JDhE7TyfT89YKpe6tcCCIqOZWdXmt9AZjjK30RkHRSFF+QEnWFQg==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0",
|
"@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0",
|
||||||
"class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
|
"class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
|
||||||
"class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0",
|
"class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0 || ^0.14.0",
|
||||||
"reflect-metadata": "^0.1.12"
|
"reflect-metadata": "^0.1.12"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -806,6 +806,37 @@
|
|||||||
"typescript": "^4.3.5"
|
"typescript": "^4.3.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/swagger": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-9M2vkfJHIzLqDZwvM5TEZO0MxRCvIb0xVy0LsmWwxH1lrb0z/4MhU+r2CWDhBtTccVJrKxVPiU2s3T3b9uUJbg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/mapped-types": "1.2.2",
|
||||||
|
"js-yaml": "4.1.0",
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"path-to-regexp": "3.2.0",
|
||||||
|
"swagger-ui-dist": "4.15.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@fastify/static": "^6.0.0",
|
||||||
|
"@nestjs/common": "^9.0.0",
|
||||||
|
"@nestjs/core": "^9.0.0",
|
||||||
|
"class-transformer": "*",
|
||||||
|
"class-validator": "*",
|
||||||
|
"reflect-metadata": "^0.1.12"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@fastify/static": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"class-transformer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"class-validator": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/testing": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
|
||||||
@@ -1955,8 +1986,7 @@
|
|||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -4431,7 +4461,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -6641,6 +6670,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swagger-ui-dist": {
|
||||||
|
"version": "4.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
|
||||||
|
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
|
||||||
|
},
|
||||||
"node_modules/symbol-observable": {
|
"node_modules/symbol-observable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
||||||
@@ -8047,9 +8081,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@nestjs/mapped-types": {
|
"@nestjs/mapped-types": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.2.tgz",
|
||||||
"integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==",
|
"integrity": "sha512-3dHxLXs3M0GPiriAcCFFJQHoDFUuzTD5w6JDhE7TyfT89YKpe6tcCCIqOZWdXmt9AZjjK30RkHRSFF+QEnWFQg==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"@nestjs/passport": {
|
"@nestjs/passport": {
|
||||||
@@ -8115,6 +8149,18 @@
|
|||||||
"pluralize": "8.0.0"
|
"pluralize": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@nestjs/swagger": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-9M2vkfJHIzLqDZwvM5TEZO0MxRCvIb0xVy0LsmWwxH1lrb0z/4MhU+r2CWDhBtTccVJrKxVPiU2s3T3b9uUJbg==",
|
||||||
|
"requires": {
|
||||||
|
"@nestjs/mapped-types": "1.2.2",
|
||||||
|
"js-yaml": "4.1.0",
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"path-to-regexp": "3.2.0",
|
||||||
|
"swagger-ui-dist": "4.15.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@nestjs/testing": {
|
"@nestjs/testing": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.2.1.tgz",
|
||||||
@@ -9041,8 +9087,7 @@
|
|||||||
"argparse": {
|
"argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"array-flatten": {
|
"array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -10908,7 +10953,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
}
|
}
|
||||||
@@ -12563,6 +12607,11 @@
|
|||||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"swagger-ui-dist": {
|
||||||
|
"version": "4.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.15.5.tgz",
|
||||||
|
"integrity": "sha512-V3eIa28lwB6gg7/wfNvAbjwJYmDXy1Jo1POjyTzlB6wPcHiGlRxq39TSjYGVjQrUSAzpv+a7nzp7mDxgNy57xA=="
|
||||||
|
},
|
||||||
"symbol-observable": {
|
"symbol-observable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
"@nestjs/core": "^9.2.1",
|
"@nestjs/core": "^9.2.1",
|
||||||
"@nestjs/jwt": "^10.0.1",
|
"@nestjs/jwt": "^10.0.1",
|
||||||
"@nestjs/mapped-types": "^1.2.0",
|
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/passport": "^9.0.0",
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
"@nestjs/platform-express": "^9.2.1",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
|
"@nestjs/swagger": "^6.2.1",
|
||||||
"@nestjs/throttler": "^3.1.0",
|
"@nestjs/throttler": "^3.1.0",
|
||||||
"@prisma/client": "^4.8.1",
|
"@prisma/client": "^4.8.1",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.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;
|
||||||
@@ -22,9 +22,10 @@ model User {
|
|||||||
loginTokens LoginToken[]
|
loginTokens LoginToken[]
|
||||||
reverseShares ReverseShare[]
|
reverseShares ReverseShare[]
|
||||||
|
|
||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
totpVerified Boolean @default(false)
|
totpVerified Boolean @default(false)
|
||||||
totpSecret String?
|
totpSecret String?
|
||||||
|
resetPasswordToken ResetPasswordToken?
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@@ -49,6 +50,16 @@ model LoginToken {
|
|||||||
used Boolean @default(false)
|
used Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ResetPasswordToken {
|
||||||
|
token String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model Share {
|
model Share {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -63,7 +74,8 @@ model Share {
|
|||||||
creatorId String?
|
creatorId String?
|
||||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
reverseShare ReverseShare?
|
reverseShareId String?
|
||||||
|
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
security ShareSecurity?
|
security ShareSecurity?
|
||||||
recipients ShareRecipient[]
|
recipients ShareRecipient[]
|
||||||
@@ -78,13 +90,12 @@ model ReverseShare {
|
|||||||
shareExpiration DateTime
|
shareExpiration DateTime
|
||||||
maxShareSize String
|
maxShareSize String
|
||||||
sendEmailNotification Boolean
|
sendEmailNotification Boolean
|
||||||
used Boolean @default(false)
|
remainingUses Int
|
||||||
|
|
||||||
creatorId String
|
creatorId String
|
||||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
shareId String? @unique
|
shares Share[]
|
||||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model ShareRecipient {
|
model ShareRecipient {
|
||||||
@@ -120,13 +131,15 @@ model ShareSecurity {
|
|||||||
model Config {
|
model Config {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
key String @id
|
name String
|
||||||
|
category String
|
||||||
type String
|
type String
|
||||||
value String
|
value String
|
||||||
description String
|
description String
|
||||||
category String
|
|
||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
|
@@id([name, category])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +1,244 @@
|
|||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
const configVariables: Prisma.ConfigCreateInput[] = [
|
const configVariables: ConfigVariables = {
|
||||||
{
|
internal: {
|
||||||
order: 0,
|
jwtSecret: {
|
||||||
key: "SETUP_STATUS",
|
description: "Long random string used to sign JWT tokens",
|
||||||
description: "Status of the setup wizard",
|
type: "string",
|
||||||
type: "string",
|
value: crypto.randomBytes(256).toString("base64"),
|
||||||
value: "STARTED", // STARTED, REGISTERED, FINISHED
|
locked: true,
|
||||||
category: "internal",
|
},
|
||||||
secret: false,
|
|
||||||
locked: true,
|
|
||||||
},
|
},
|
||||||
{
|
general: {
|
||||||
order: 0,
|
appName: {
|
||||||
key: "JWT_SECRET",
|
description: "Name of the application",
|
||||||
description: "Long random string used to sign JWT tokens",
|
type: "string",
|
||||||
type: "string",
|
value: "Pingvin Share",
|
||||||
value: crypto.randomBytes(256).toString("base64"),
|
secret: false,
|
||||||
category: "internal",
|
},
|
||||||
locked: true,
|
appUrl: {
|
||||||
},
|
description: "On which URL Pingvin Share is available",
|
||||||
{
|
type: "string",
|
||||||
order: 0,
|
value: "http://localhost:3000",
|
||||||
key: "TOTP_SECRET",
|
|
||||||
description: "A 16 byte random string used to generate TOTP secrets",
|
|
||||||
type: "string",
|
|
||||||
value: crypto.randomBytes(16).toString("base64"),
|
|
||||||
category: "internal",
|
|
||||||
locked: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 1,
|
|
||||||
key: "APP_URL",
|
|
||||||
description: "On which URL Pingvin Share is available",
|
|
||||||
type: "string",
|
|
||||||
value: "http://localhost:3000",
|
|
||||||
category: "general",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 2,
|
|
||||||
key: "SHOW_HOME_PAGE",
|
|
||||||
description: "Whether to show the home page",
|
|
||||||
type: "boolean",
|
|
||||||
value: "true",
|
|
||||||
category: "general",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 3,
|
|
||||||
key: "ALLOW_REGISTRATION",
|
|
||||||
description: "Whether registration is allowed",
|
|
||||||
type: "boolean",
|
|
||||||
value: "true",
|
|
||||||
category: "share",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 4,
|
|
||||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
|
||||||
description: "Whether unauthorized users can create shares",
|
|
||||||
type: "boolean",
|
|
||||||
value: "false",
|
|
||||||
category: "share",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 5,
|
|
||||||
|
|
||||||
key: "MAX_SHARE_SIZE",
|
secret: false,
|
||||||
description: "Maximum share size in bytes",
|
},
|
||||||
type: "number",
|
showHomePage: {
|
||||||
value: "1073741824",
|
description: "Whether to show the home page",
|
||||||
category: "share",
|
type: "boolean",
|
||||||
secret: false,
|
value: "true",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
share: {
|
||||||
|
allowRegistration: {
|
||||||
|
description: "Whether registration is allowed",
|
||||||
|
type: "boolean",
|
||||||
|
value: "true",
|
||||||
|
|
||||||
{
|
secret: false,
|
||||||
order: 6,
|
},
|
||||||
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
|
allowUnauthenticatedShares: {
|
||||||
description:
|
description: "Whether unauthorized users can create shares",
|
||||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
type: "boolean",
|
||||||
type: "boolean",
|
value: "false",
|
||||||
value: "false",
|
|
||||||
category: "email",
|
secret: false,
|
||||||
secret: false,
|
},
|
||||||
|
maxSize: {
|
||||||
|
description: "Maximum share size in bytes",
|
||||||
|
type: "number",
|
||||||
|
value: "1073741824",
|
||||||
|
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
email: {
|
||||||
order: 7,
|
enableShareEmailRecipients: {
|
||||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
description:
|
||||||
description:
|
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
type: "boolean",
|
||||||
type: "text",
|
value: "false",
|
||||||
value:
|
|
||||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
secret: false,
|
||||||
category: "email",
|
},
|
||||||
|
shareRecipientsSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent to the share recipients.",
|
||||||
|
type: "string",
|
||||||
|
value: "Files shared with you",
|
||||||
|
},
|
||||||
|
shareRecipientsMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||||
|
type: "text",
|
||||||
|
value:
|
||||||
|
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||||
|
},
|
||||||
|
reverseShareSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||||
|
type: "string",
|
||||||
|
value: "Reverse share link used",
|
||||||
|
},
|
||||||
|
reverseShareMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||||
|
type: "text",
|
||||||
|
value:
|
||||||
|
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||||
|
},
|
||||||
|
resetPasswordSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when a user requests a password reset.",
|
||||||
|
type: "string",
|
||||||
|
value: "Pingvin Share password reset",
|
||||||
|
},
|
||||||
|
resetPasswordMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||||
|
type: "text",
|
||||||
|
value:
|
||||||
|
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
||||||
|
},
|
||||||
|
inviteSubject: {
|
||||||
|
description:
|
||||||
|
"Subject of the email which gets sent when an admin invites an user.",
|
||||||
|
type: "string",
|
||||||
|
value: "Pingvin Share invite",
|
||||||
|
},
|
||||||
|
inviteMessage: {
|
||||||
|
description:
|
||||||
|
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
|
||||||
|
type: "text",
|
||||||
|
value:
|
||||||
|
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
smtp: {
|
||||||
order: 8,
|
enabled: {
|
||||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
description:
|
||||||
description:
|
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||||
"Subject of the email which gets sent to the share recipients.",
|
type: "boolean",
|
||||||
type: "string",
|
value: "false",
|
||||||
value: "Files shared with you",
|
secret: false,
|
||||||
category: "email",
|
},
|
||||||
|
host: {
|
||||||
|
description: "Host of the SMTP server",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
port: {
|
||||||
|
description: "Port of the SMTP server",
|
||||||
|
type: "number",
|
||||||
|
value: "0",
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
description: "Email address which the emails get sent from",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
description: "Username of the SMTP server",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
description: "Password of the SMTP server",
|
||||||
|
type: "string",
|
||||||
|
value: "",
|
||||||
|
obscured: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
order: 9,
|
|
||||||
key: "REVERSE_SHARE_EMAIL_MESSAGE",
|
type ConfigVariables = {
|
||||||
description:
|
[category: string]: {
|
||||||
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
[variable: string]: Omit<
|
||||||
type: "text",
|
Prisma.ConfigCreateInput,
|
||||||
value:
|
"name" | "category" | "order"
|
||||||
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
>;
|
||||||
category: "email",
|
};
|
||||||
},
|
};
|
||||||
{
|
|
||||||
order: 10,
|
|
||||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
|
||||||
type: "string",
|
|
||||||
value: "Reverse share link used",
|
|
||||||
category: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 11,
|
|
||||||
key: "SMTP_ENABLED",
|
|
||||||
description:
|
|
||||||
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
|
||||||
type: "boolean",
|
|
||||||
value: "false",
|
|
||||||
category: "smtp",
|
|
||||||
secret: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 12,
|
|
||||||
key: "SMTP_HOST",
|
|
||||||
description: "Host of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 13,
|
|
||||||
key: "SMTP_PORT",
|
|
||||||
description: "Port of the SMTP server",
|
|
||||||
type: "number",
|
|
||||||
value: "0",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 14,
|
|
||||||
key: "SMTP_EMAIL",
|
|
||||||
description: "Email address which the emails get sent from",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 15,
|
|
||||||
key: "SMTP_USERNAME",
|
|
||||||
description: "Username of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
order: 16,
|
|
||||||
key: "SMTP_PASSWORD",
|
|
||||||
description: "Password of the SMTP server",
|
|
||||||
type: "string",
|
|
||||||
value: "",
|
|
||||||
obscured: true,
|
|
||||||
category: "smtp",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function seedConfigVariables() {
|
||||||
for (const variable of configVariables) {
|
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||||
const existingConfigVariable = await prisma.config.findUnique({
|
configVariables
|
||||||
where: { key: variable.key },
|
)) {
|
||||||
});
|
let order = 0;
|
||||||
|
for (const [name, properties] of Object.entries(
|
||||||
// Create a new config variable if it doesn't exist
|
configVariablesOfCategory
|
||||||
if (!existingConfigVariable) {
|
)) {
|
||||||
await prisma.config.create({
|
const existingConfigVariable = await prisma.config.findUnique({
|
||||||
data: variable,
|
where: { name_category: { name, category } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a new config variable if it doesn't exist
|
||||||
|
if (!existingConfigVariable) {
|
||||||
|
await prisma.config.create({
|
||||||
|
data: {
|
||||||
|
order,
|
||||||
|
name,
|
||||||
|
...properties,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
order++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configVariablesFromDatabase = await prisma.config.findMany();
|
async function migrateConfigVariables() {
|
||||||
|
const existingConfigVariables = await prisma.config.findMany();
|
||||||
|
|
||||||
// Delete the config variable if it doesn't exist anymore
|
for (const existingConfigVariable of existingConfigVariables) {
|
||||||
for (const configVariableFromDatabase of configVariablesFromDatabase) {
|
const configVariable =
|
||||||
const configVariable = configVariables.find(
|
configVariables[existingConfigVariable.category]?.[
|
||||||
(v) => v.key == configVariableFromDatabase.key
|
existingConfigVariable.name
|
||||||
);
|
];
|
||||||
if (!configVariable) {
|
if (!configVariable) {
|
||||||
await prisma.config.delete({
|
await prisma.config.delete({
|
||||||
where: { key: configVariableFromDatabase.key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
name: existingConfigVariable.name,
|
||||||
|
category: existingConfigVariable.category,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the config variable if the metadata changed
|
// Update the config variable if the metadata changed
|
||||||
} else if (
|
} else if (
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...configVariable,
|
...configVariable,
|
||||||
key: configVariableFromDatabase.key,
|
name: existingConfigVariable.name,
|
||||||
value: configVariableFromDatabase.value,
|
category: existingConfigVariable.category,
|
||||||
}) != JSON.stringify(configVariableFromDatabase)
|
value: existingConfigVariable.value,
|
||||||
|
}) != JSON.stringify(existingConfigVariable)
|
||||||
) {
|
) {
|
||||||
await prisma.config.update({
|
await prisma.config.update({
|
||||||
where: { key: configVariableFromDatabase.key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
name: existingConfigVariable.name,
|
||||||
|
category: existingConfigVariable.category,
|
||||||
|
},
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
...configVariable,
|
...configVariable,
|
||||||
key: configVariableFromDatabase.key,
|
name: existingConfigVariable.name,
|
||||||
value: configVariableFromDatabase.value,
|
category: existingConfigVariable.category,
|
||||||
|
value: existingConfigVariable.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main()
|
|
||||||
|
seedConfigVariables()
|
||||||
|
.then(() => migrateConfigVariables())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
@@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
|||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||||
|
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||||
import { TokenDTO } from "./dto/token.dto";
|
import { TokenDTO } from "./dto/token.dto";
|
||||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||||
@@ -34,14 +36,15 @@ export class AuthController {
|
|||||||
private config: ConfigService
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
async signUp(
|
async signUp(
|
||||||
@Body() dto: AuthRegisterDTO,
|
@Body() dto: AuthRegisterDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("share.allowRegistration"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
@@ -53,8 +56,8 @@ export class AuthController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signIn(
|
async signIn(
|
||||||
@Body() dto: AuthSignInDTO,
|
@Body() dto: AuthSignInDTO,
|
||||||
@@ -73,8 +76,8 @@ export class AuthController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
|
@Throttle(10, 5 * 60)
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signInTotp(
|
async signInTotp(
|
||||||
@Body() dto: AuthSignInTotpDTO,
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
@@ -91,6 +94,20 @@ export class AuthController {
|
|||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("resetPassword/:email")
|
||||||
|
@Throttle(5, 5 * 60)
|
||||||
|
@HttpCode(204)
|
||||||
|
async requestResetPassword(@Param("email") email: string) {
|
||||||
|
return await this.authService.requestResetPassword(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("resetPassword")
|
||||||
|
@Throttle(5, 5 * 60)
|
||||||
|
@HttpCode(204)
|
||||||
|
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||||
|
return await this.authService.resetPassword(dto.token, dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("password")
|
@Patch("password")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async updatePassword(
|
async updatePassword(
|
||||||
@@ -119,7 +136,7 @@ export class AuthController {
|
|||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
request.cookies.refresh_token
|
request.cookies.refresh_token
|
||||||
);
|
);
|
||||||
response.cookie("access_token", accessToken);
|
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||||
return new TokenDTO().from({ accessToken });
|
return new TokenDTO().from({ accessToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +178,13 @@ export class AuthController {
|
|||||||
refreshToken?: string,
|
refreshToken?: string,
|
||||||
accessToken?: string
|
accessToken?: string
|
||||||
) {
|
) {
|
||||||
if (accessToken) response.cookie("access_token", accessToken);
|
if (accessToken)
|
||||||
|
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||||
if (refreshToken)
|
if (refreshToken)
|
||||||
response.cookie("refresh_token", refreshToken, {
|
response.cookie("refresh_token", refreshToken, {
|
||||||
path: "/api/auth/token",
|
path: "/api/auth/token",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthTotpService } from "./authTotp.service";
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({}), EmailModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
|||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
@@ -19,11 +20,12 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
|
private emailService: EmailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED";
|
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||||
|
|
||||||
const hash = await argon.hash(dto.password);
|
const hash = await argon.hash(dto.password);
|
||||||
try {
|
try {
|
||||||
@@ -36,10 +38,6 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFirstUser) {
|
|
||||||
await this.config.changeSetupStatus("REGISTERED");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
@@ -87,6 +85,50 @@ export class AuthService {
|
|||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestResetPassword(email: string) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { email },
|
||||||
|
include: { resetPasswordToken: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new BadRequestException("User not found");
|
||||||
|
|
||||||
|
// Delete old reset password token
|
||||||
|
if (user.resetPasswordToken) {
|
||||||
|
await this.prisma.resetPasswordToken.delete({
|
||||||
|
where: { token: user.resetPasswordToken.token },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await this.prisma.resetPasswordToken.create({
|
||||||
|
data: {
|
||||||
|
expiresAt: moment().add(1, "hour").toDate(),
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { resetPasswordToken: { token } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||||
|
|
||||||
|
const newPasswordHash = await argon.hash(newPassword);
|
||||||
|
|
||||||
|
await this.prisma.resetPasswordToken.delete({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: newPasswordHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||||
if (!(await argon.verify(user.password, oldPassword)))
|
if (!(await argon.verify(user.password, oldPassword)))
|
||||||
throw new ForbiddenException("Invalid password");
|
throw new ForbiddenException("Invalid password");
|
||||||
@@ -110,26 +152,30 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
refreshTokenId,
|
refreshTokenId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut(accessToken: string) {
|
async signOut(accessToken: string) {
|
||||||
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
const { refreshTokenId } =
|
||||||
refreshTokenId: string;
|
(this.jwtService.decode(accessToken) as {
|
||||||
};
|
refreshTokenId: string;
|
||||||
|
}) || {};
|
||||||
|
|
||||||
await this.prisma.refreshToken
|
if (refreshTokenId) {
|
||||||
.delete({ where: { id: refreshTokenId } })
|
await this.prisma.refreshToken
|
||||||
.catch((e) => {
|
.delete({ where: { id: refreshTokenId } })
|
||||||
// Ignore error if refresh token doesn't exist
|
.catch((e) => {
|
||||||
if (e.code != "P2025") throw e;
|
// Ignore error if refresh token doesn't exist
|
||||||
});
|
if (e.code != "P2025") throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string) {
|
async refreshAccessToken(refreshToken: string) {
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { authenticator, totp } from "otplib";
|
import { authenticator, totp } from "otplib";
|
||||||
import * as qrcode from "qrcode-svg";
|
import * as qrcode from "qrcode-svg";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthTotpService {
|
export class AuthTotpService {
|
||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private authService: AuthService
|
private authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
@@ -57,9 +54,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (dto.totp !== expected) {
|
if (dto.totp !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
@@ -81,41 +76,6 @@ export class AuthTotpService {
|
|||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptTotpSecret(totpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
||||||
|
|
||||||
let encrypted = cipher.update(totpSecret);
|
|
||||||
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
|
|
||||||
return encrypted.toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
||||||
let decrypted = decipher.update(encryptedText);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
|
|
||||||
return decrypted.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableTotp(user: User, password: string) {
|
async enableTotp(user: User, password: string) {
|
||||||
if (!(await argon.verify(user.password, password)))
|
if (!(await argon.verify(user.password, password)))
|
||||||
throw new ForbiddenException("Invalid password");
|
throw new ForbiddenException("Invalid password");
|
||||||
@@ -132,7 +92,6 @@ export class AuthTotpService {
|
|||||||
|
|
||||||
// TODO: Maybe make the issuer configurable with env vars?
|
// TODO: Maybe make the issuer configurable with env vars?
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
|
||||||
|
|
||||||
const otpURL = totp.keyuri(
|
const otpURL = totp.keyuri(
|
||||||
user.username || user.email,
|
user.username || user.email,
|
||||||
@@ -144,7 +103,7 @@ export class AuthTotpService {
|
|||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
totpSecret: encryptedSecret,
|
totpSecret: secret,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,9 +136,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not in progress");
|
throw new BadRequestException("TOTP is not in progress");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
if (code !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
@@ -208,9 +165,7 @@ export class AuthTotpService {
|
|||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
const expected = authenticator.generate(totpSecret);
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
if (code !== expected) {
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class AuthRegisterDTO extends PickType(UserDTO, [
|
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { IsString } from "class-validator";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
|
||||||
|
|
||||||
export class AuthSignInTotpDTO extends PickType(UserDTO, [
|
|
||||||
"password",
|
|
||||||
] as const) {
|
|
||||||
@IsEmail()
|
|
||||||
@IsOptional()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
|
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
totp: string;
|
totp: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||||
|
|||||||
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { IsString } from "class-validator";
|
||||||
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") {
|
|||||||
try {
|
try {
|
||||||
return (await super.canActivate(context)) as boolean;
|
return (await super.canActivate(context)) as boolean;
|
||||||
} catch {
|
} catch {
|
||||||
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
return this.config.get("share.allowUnauthenticatedShares");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||||
config.get("JWT_SECRET");
|
config.get("internal.jwtSecret");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: JwtStrategy.extractJWT,
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
secretOrKey: config.get("JWT_SECRET"),
|
secretOrKey: config.get("internal.jwtSecret"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
@@ -16,28 +25,25 @@ export class ConfigController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@SkipThrottle()
|
||||||
async list() {
|
async list() {
|
||||||
return new ConfigDTO().fromList(await this.configService.list());
|
return new ConfigDTO().fromList(await this.configService.list());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("admin")
|
@Get("admin/:category")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async listForAdmin() {
|
async getByCategory(@Param("category") category: string) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.listForAdmin()
|
await this.configService.getByCategory(category)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("admin")
|
@Patch("admin")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
await this.configService.updateMany(data);
|
return new AdminConfigDTO().fromList(
|
||||||
}
|
await this.configService.updateMany(data)
|
||||||
|
);
|
||||||
@Post("admin/finishSetup")
|
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
|
||||||
async finishSetup() {
|
|
||||||
return await this.configService.changeSetupStatus("FINISHED");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("admin/testEmail")
|
@Post("admin/testEmail")
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export class ConfigService {
|
|||||||
private prisma: PrismaService
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(key: string): any {
|
get(key: `${string}.${string}`): any {
|
||||||
const configVariable = this.configVariables.filter(
|
const configVariable = this.configVariables.filter(
|
||||||
(variable) => variable.key == key
|
(variable) => `${variable.category}.${variable.name}` == key
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||||
@@ -27,30 +27,51 @@ export class ConfigService {
|
|||||||
return configVariable.value;
|
return configVariable.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listForAdmin() {
|
async getByCategory(category: string) {
|
||||||
return await this.prisma.config.findMany({
|
const configVariables = await this.prisma.config.findMany({
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
where: { locked: { equals: false } },
|
where: { category, locked: { equals: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return configVariables.map((variable) => {
|
||||||
|
return {
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
...variable,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.config.findMany({
|
const configVariables = await this.prisma.config.findMany({
|
||||||
where: { secret: { equals: false } },
|
where: { secret: { equals: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return configVariables.map((variable) => {
|
||||||
|
return {
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
...variable,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||||
|
const response: Config[] = [];
|
||||||
|
|
||||||
for (const variable of data) {
|
for (const variable of data) {
|
||||||
await this.update(variable.key, variable.value);
|
response.push(await this.update(variable.key, variable.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(key: string, value: string | number | boolean) {
|
async update(key: string, value: string | number | boolean) {
|
||||||
const configVariable = await this.prisma.config.findUnique({
|
const configVariable = await this.prisma.config.findUnique({
|
||||||
where: { key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
category: key.split(".")[0],
|
||||||
|
name: key.split(".")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configVariable || configVariable.locked)
|
if (!configVariable || configVariable.locked)
|
||||||
@@ -67,7 +88,12 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedVariable = await this.prisma.config.update({
|
const updatedVariable = await this.prisma.config.update({
|
||||||
where: { key },
|
where: {
|
||||||
|
name_category: {
|
||||||
|
category: key.split(".")[0],
|
||||||
|
name: key.split(".")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
data: { value: value.toString() },
|
data: { value: value.toString() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,11 +101,4 @@ export class ConfigService {
|
|||||||
|
|
||||||
return updatedVariable;
|
return updatedVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
|
|
||||||
return await this.prisma.config.update({
|
|
||||||
where: { key: "SETUP_STATUS" },
|
|
||||||
data: { value: status },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Expose, plainToClass } from "class-transformer";
|
|||||||
import { ConfigDTO } from "./config.dto";
|
import { ConfigDTO } from "./config.dto";
|
||||||
|
|
||||||
export class AdminConfigDTO extends ConfigDTO {
|
export class AdminConfigDTO extends ConfigDTO {
|
||||||
|
@Expose()
|
||||||
|
name: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
|
|
||||||
@@ -14,9 +17,6 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
category: string;
|
|
||||||
|
|
||||||
from(partial: Partial<AdminConfigDTO>) {
|
from(partial: Partial<AdminConfigDTO>) {
|
||||||
return plainToClass(AdminConfigDTO, partial, {
|
return plainToClass(AdminConfigDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ export class EmailService {
|
|||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
getTransporter() {
|
getTransporter() {
|
||||||
if (!this.config.get("SMTP_ENABLED"))
|
if (!this.config.get("smtp.enabled"))
|
||||||
throw new InternalServerErrorException("SMTP is disabled");
|
throw new InternalServerErrorException("SMTP is disabled");
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return nodemailer.createTransport({
|
||||||
host: this.config.get("SMTP_HOST"),
|
host: this.config.get("smtp.host"),
|
||||||
port: parseInt(this.config.get("SMTP_PORT")),
|
port: this.config.get("smtp.port"),
|
||||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
secure: this.config.get("smtp.port") == 465,
|
||||||
auth: {
|
auth: {
|
||||||
user: this.config.get("SMTP_USERNAME"),
|
user: this.config.get("smtp.username"),
|
||||||
pass: this.config.get("SMTP_PASSWORD"),
|
pass: this.config.get("smtp.password"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -27,17 +27,19 @@ export class EmailService {
|
|||||||
shareId: string,
|
shareId: string,
|
||||||
creator?: User
|
creator?: User
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS"))
|
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"smtp.email"
|
||||||
|
)}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
|
subject: this.config.get("email.shareRecipientsSubject"),
|
||||||
text: this.config
|
text: this.config
|
||||||
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
|
.get("email.shareRecipientsMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
@@ -45,25 +47,67 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"smtp.email"
|
||||||
|
)}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
|
subject: this.config.get("email.reverseShareSubject"),
|
||||||
text: this.config
|
text: this.config
|
||||||
.get("REVERSE_SHARE_EMAIL_MESSAGE")
|
.get("email.reverseShareMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||||
|
const resetPasswordUrl = `${this.config.get(
|
||||||
|
"general.appUrl"
|
||||||
|
)}/auth/resetPassword/${token}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"smtp.email"
|
||||||
|
)}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Test email",
|
subject: this.config.get("email.resetPasswordSubject"),
|
||||||
text: "This is a test email",
|
text: this.config
|
||||||
|
.get("email.resetPasswordMessage")
|
||||||
|
.replaceAll("{url}", resetPasswordUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||||
|
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||||
|
|
||||||
|
await this.getTransporter().sendMail({
|
||||||
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"smtp.email"
|
||||||
|
)}>`,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: this.config.get("email.inviteSubject"),
|
||||||
|
text: this.config
|
||||||
|
.get("email.inviteMessage")
|
||||||
|
.replaceAll("{url}", loginUrl)
|
||||||
|
.replaceAll("{password}", password),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestMail(recipientEmail: string) {
|
||||||
|
try {
|
||||||
|
await this.getTransporter().sendMail({
|
||||||
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
|
"smtp.email"
|
||||||
|
)}>`,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: "Test email",
|
||||||
|
text: "This is a test email",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new InternalServerErrorException(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ import {
|
|||||||
import { SkipThrottle } from "@nestjs/throttler";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import * as contentDisposition from "content-disposition";
|
import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
|
||||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
|
||||||
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||||
|
|
||||||
@Controller("shares/:shareId/files")
|
@Controller("shares/:shareId/files")
|
||||||
export class FileController {
|
export class FileController {
|
||||||
@@ -44,30 +42,8 @@ export class FileController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId/download")
|
|
||||||
@UseGuards(ShareSecurityGuard)
|
|
||||||
async getFileDownloadUrl(
|
|
||||||
@Param("shareId") shareId: string,
|
|
||||||
@Param("fileId") fileId: string
|
|
||||||
) {
|
|
||||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
|
||||||
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("zip/download")
|
|
||||||
@UseGuards(ShareSecurityGuard)
|
|
||||||
async getZipArchiveDownloadURL(
|
|
||||||
@Param("shareId") shareId: string,
|
|
||||||
@Param("fileId") fileId: string
|
|
||||||
) {
|
|
||||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
|
||||||
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("zip")
|
@Get("zip")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
@@ -75,25 +51,32 @@ export class FileController {
|
|||||||
const zip = this.fileService.getZip(shareId);
|
const zip = this.fileService.getZip(shareId);
|
||||||
res.set({
|
res.set({
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(zip);
|
return new StreamableFile(zip);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId")
|
@Get(":fileId")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string
|
@Param("fileId") fileId: string,
|
||||||
|
@Query("download") download = "true"
|
||||||
) {
|
) {
|
||||||
const file = await this.fileService.get(shareId, fileId);
|
const file = await this.fileService.get(shareId, fileId);
|
||||||
res.set({
|
|
||||||
|
const headers = {
|
||||||
"Content-Type": file.metaData.mimeType,
|
"Content-Type": file.metaData.mimeType,
|
||||||
"Content-Length": file.metaData.size,
|
"Content-Length": file.metaData.size,
|
||||||
"Content-Disposition": contentDisposition(file.metaData.name),
|
};
|
||||||
});
|
|
||||||
|
if (download === "true") {
|
||||||
|
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set(headers);
|
||||||
|
|
||||||
return new StreamableFile(file.file);
|
return new StreamableFile(file.file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class FileService {
|
|||||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
|
shareSizeSum > this.config.get("share.maxSize") ||
|
||||||
(share.reverseShare?.maxShareSize &&
|
(share.reverseShare?.maxShareSize &&
|
||||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||||
) {
|
) {
|
||||||
@@ -135,38 +135,4 @@ export class FileService {
|
|||||||
getZip(shareId: string) {
|
getZip(shareId: string) {
|
||||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
|
||||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
|
||||||
|
|
||||||
return `${this.config.get(
|
|
||||||
"APP_URL"
|
|
||||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateFileDownloadToken(shareId: string, fileId: string) {
|
|
||||||
if (fileId == "zip") fileId = undefined;
|
|
||||||
|
|
||||||
return this.jwtService.sign(
|
|
||||||
{
|
|
||||||
shareId,
|
|
||||||
fileId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expiresIn: "10min",
|
|
||||||
secret: this.config.get("JWT_SECRET"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyFileDownloadToken(shareId: string, token: string) {
|
|
||||||
try {
|
|
||||||
const claims = this.jwtService.verify(token, {
|
|
||||||
secret: this.config.get("JWT_SECRET"),
|
|
||||||
});
|
|
||||||
return claims.shareId == shareId;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
|
||||||
import { FileService } from "src/file/file.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FileDownloadGuard implements CanActivate {
|
|
||||||
constructor(private fileService: FileService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const token = request.query.token as string;
|
|
||||||
const { shareId } = request.params;
|
|
||||||
|
|
||||||
return this.fileService.verifyFileDownloadToken(shareId, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
backend/src/file/guard/fileSecurity.guard.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import * as moment from "moment";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
|
import { ShareService } from "src/share/share.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
|
constructor(
|
||||||
|
private _shareService: ShareService,
|
||||||
|
private _prisma: PrismaService
|
||||||
|
) {
|
||||||
|
super(_shareService, _prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
|
request.params,
|
||||||
|
"shareId"
|
||||||
|
)
|
||||||
|
? request.params.shareId
|
||||||
|
: request.params.id;
|
||||||
|
|
||||||
|
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||||
|
|
||||||
|
const share = await this._prisma.share.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: { security: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is no share token the user requests a file directly
|
||||||
|
if (!shareToken) {
|
||||||
|
if (
|
||||||
|
!share ||
|
||||||
|
(moment().isAfter(share.expiration) &&
|
||||||
|
!moment(share.expiration).isSame(0))
|
||||||
|
) {
|
||||||
|
throw new NotFoundException("File not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.security?.password)
|
||||||
|
throw new ForbiddenException("This share is password protected");
|
||||||
|
|
||||||
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Maximum views exceeded",
|
||||||
|
"share_max_views_exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._shareService.increaseViewCount(share);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { FileModule } from "src/file/file.module";
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ReverseShareModule } from "src/reverseShare/reverseShare.module";
|
||||||
import { JobsService } from "./jobs.service";
|
import { JobsService } from "./jobs.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FileModule],
|
imports: [FileModule, ReverseShareModule],
|
||||||
providers: [JobsService],
|
providers: [JobsService],
|
||||||
})
|
})
|
||||||
export class JobsModule {}
|
export class JobsModule {}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import * as fs from "fs";
|
|||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobsService {
|
export class JobsService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private reverseShareService: ReverseShareService,
|
||||||
private fileService: FileService
|
private fileService: FileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,6 +38,24 @@ export class JobsService {
|
|||||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron("0 * * * *")
|
||||||
|
async deleteExpiredReverseShares() {
|
||||||
|
const expiredReverseShares = await this.prisma.reverseShare.findMany({
|
||||||
|
where: {
|
||||||
|
shareExpiration: { lt: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const expiredReverseShare of expiredReverseShares) {
|
||||||
|
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredReverseShares.length > 0)
|
||||||
|
console.log(
|
||||||
|
`job: deleted ${expiredReverseShares.length} expired reverse shares`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Cron("0 0 * * *")
|
@Cron("0 0 * * *")
|
||||||
deleteTemporaryFiles() {
|
deleteTemporaryFiles() {
|
||||||
let filesDeleted = 0;
|
let filesDeleted = 0;
|
||||||
@@ -69,14 +89,25 @@ export class JobsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
async deleteExpiredRefreshTokens() {
|
async deleteExpiredTokens() {
|
||||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
const { count: refreshTokenCount } =
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { expiresAt: { lt: new Date() } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { count: loginTokenCount } = await this.prisma.loginToken.deleteMany({
|
||||||
where: { expiresAt: { lt: new Date() } },
|
where: { expiresAt: { lt: new Date() } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (expiredRefreshTokens.count > 0)
|
const { count: resetPasswordTokenCount } =
|
||||||
console.log(
|
await this.prisma.resetPasswordToken.deleteMany({
|
||||||
`job: deleted ${expiredRefreshTokens.count} expired refresh tokens`
|
where: { expiresAt: { lt: new Date() } },
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const deletedTokensCount =
|
||||||
|
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||||
|
|
||||||
|
if (deletedTokensCount > 0)
|
||||||
|
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import * as bodyParser from "body-parser";
|
import * as bodyParser from "body-parser";
|
||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@@ -18,6 +19,17 @@ async function bootstrap() {
|
|||||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||||
|
|
||||||
app.setGlobalPrefix("api");
|
app.setGlobalPrefix("api");
|
||||||
|
|
||||||
|
// Setup Swagger in development mode
|
||||||
|
if (process.env.NODE_ENV == "development") {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle("Pingvin Share API")
|
||||||
|
.setVersion("1.0")
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup("api/swagger", app, document);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(8080);
|
await app.listen(8080);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsBoolean, IsString } from "class-validator";
|
import { IsBoolean, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
export class CreateReverseShareDTO {
|
export class CreateReverseShareDTO {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -9,4 +9,8 @@ export class CreateReverseShareDTO {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
shareExpiration: string;
|
shareExpiration: string;
|
||||||
|
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
maxUseCount: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { OmitType } from "@nestjs/mapped-types";
|
|
||||||
import { Expose, plainToClass, Type } from "class-transformer";
|
|
||||||
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
|
||||||
import { ReverseShareDTO } from "./reverseShare.dto";
|
|
||||||
|
|
||||||
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
|
|
||||||
"shareExpiration",
|
|
||||||
] as const) {
|
|
||||||
@Expose()
|
|
||||||
shareExpiration: Date;
|
|
||||||
|
|
||||||
@Expose()
|
|
||||||
@Type(() => OmitType(MyShareDTO, ["recipients"] as const))
|
|
||||||
share: Omit<MyShareDTO, "recipients" | "files" | "from" | "fromList">;
|
|
||||||
|
|
||||||
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
|
||||||
return partial.map((part) =>
|
|
||||||
plainToClass(ReverseShareTokenWithShare, part, {
|
|
||||||
excludeExtraneousValues: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
backend/src/reverseShare/dto/reverseShareTokenWithShares.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { OmitType } from "@nestjs/swagger";
|
||||||
|
import { Expose, plainToClass, Type } from "class-transformer";
|
||||||
|
import { MyShareDTO } from "src/share/dto/myShare.dto";
|
||||||
|
import { ReverseShareDTO } from "./reverseShare.dto";
|
||||||
|
|
||||||
|
export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||||
|
"shareExpiration",
|
||||||
|
] as const) {
|
||||||
|
@Expose()
|
||||||
|
shareExpiration: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||||
|
shares: Omit<
|
||||||
|
MyShareDTO,
|
||||||
|
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||||
|
>[];
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
remainingUses: number;
|
||||||
|
|
||||||
|
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
|
||||||
|
return partial.map((part) =>
|
||||||
|
plainToClass(ReverseShareTokenWithShares, part, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { JwtGuard } from "src/auth/guard/jwt.guard";
|
|||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||||
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
import { ReverseShareDTO } from "./dto/reverseShare.dto";
|
||||||
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare";
|
import { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
|
||||||
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
|
||||||
import { ReverseShareService } from "./reverseShare.service";
|
import { ReverseShareService } from "./reverseShare.service";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export class ReverseShareController {
|
|||||||
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
||||||
const token = await this.reverseShareService.create(body, user.id);
|
const token = await this.reverseShareService.create(body, user.id);
|
||||||
|
|
||||||
const link = `${this.config.get("APP_URL")}/upload/${token}`;
|
const link = `${this.config.get("general.appUrl")}/upload/${token}`;
|
||||||
|
|
||||||
return { token, link };
|
return { token, link };
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ export class ReverseShareController {
|
|||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getAllByUser(@GetUser() user: User) {
|
async getAllByUser(@GetUser() user: User) {
|
||||||
return new ReverseShareTokenWithShare().fromList(
|
return new ReverseShareTokenWithShares().fromList(
|
||||||
await this.reverseShareService.getAllByUser(user.id)
|
await this.reverseShareService.getAllByUser(user.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class ReverseShareService {
|
|||||||
)
|
)
|
||||||
.toDate();
|
.toDate();
|
||||||
|
|
||||||
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
|
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||||
|
|
||||||
if (globalMaxShareSize < data.maxShareSize)
|
if (globalMaxShareSize < data.maxShareSize)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -34,6 +34,7 @@ export class ReverseShareService {
|
|||||||
const reverseShare = await this.prisma.reverseShare.create({
|
const reverseShare = await this.prisma.reverseShare.create({
|
||||||
data: {
|
data: {
|
||||||
shareExpiration: expirationDate,
|
shareExpiration: expirationDate,
|
||||||
|
remainingUses: data.maxUseCount,
|
||||||
maxShareSize: data.maxShareSize,
|
maxShareSize: data.maxShareSize,
|
||||||
sendEmailNotification: data.sendEmailNotification,
|
sendEmailNotification: data.sendEmailNotification,
|
||||||
creatorId,
|
creatorId,
|
||||||
@@ -43,7 +44,9 @@ export class ReverseShareService {
|
|||||||
return reverseShare.token;
|
return reverseShare.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByToken(reverseShareToken: string) {
|
async getByToken(reverseShareToken?: string) {
|
||||||
|
if (!reverseShareToken) return null;
|
||||||
|
|
||||||
const reverseShare = await this.prisma.reverseShare.findUnique({
|
const reverseShare = await this.prisma.reverseShare.findUnique({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
});
|
});
|
||||||
@@ -60,7 +63,7 @@ export class ReverseShareService {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
shareExpiration: "desc",
|
shareExpiration: "desc",
|
||||||
},
|
},
|
||||||
include: { share: { include: { creator: true } } },
|
include: { shares: { include: { creator: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reverseShares;
|
return reverseShares;
|
||||||
@@ -74,21 +77,21 @@ export class ReverseShareService {
|
|||||||
if (!reverseShare) return false;
|
if (!reverseShare) return false;
|
||||||
|
|
||||||
const isExpired = new Date() > reverseShare.shareExpiration;
|
const isExpired = new Date() > reverseShare.shareExpiration;
|
||||||
const isUsed = reverseShare.used;
|
const remainingUsesExceeded = reverseShare.remainingUses <= 0;
|
||||||
|
|
||||||
return !(isExpired || isUsed);
|
return !(isExpired || remainingUsesExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
const share = await this.prisma.share.findFirst({
|
const shares = await this.prisma.share.findMany({
|
||||||
where: { reverseShare: { id } },
|
where: { reverseShare: { id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (share) {
|
for (const share of shares) {
|
||||||
await this.prisma.share.delete({ where: { id: share.id } });
|
await this.prisma.share.delete({ where: { id: share.id } });
|
||||||
await this.fileService.deleteAllFiles(share.id);
|
await this.fileService.deleteAllFiles(share.id);
|
||||||
} else {
|
|
||||||
await this.prisma.reverseShare.delete({ where: { id } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.prisma.reverseShare.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
hasPassword: boolean;
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareToken = request.get("X-Share-Token");
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId"
|
||||||
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|
||||||
|
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true },
|
include: { security: true },
|
||||||
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
if (
|
if (
|
||||||
!share ||
|
!share ||
|
||||||
(moment().isAfter(share.expiration) &&
|
(moment().isAfter(share.expiration) &&
|
||||||
moment(share.expiration).unix() !== 0)
|
!moment(share.expiration).isSame(0))
|
||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
|
|||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
if (share.security?.maxViews && share.security.maxViews <= share.views)
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"Maximum views exceeded",
|
|
||||||
"share_max_views_exceeded"
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request, Response } from "express";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
import { MyShareDTO } from "./dto/myShare.dto";
|
import { MyShareDTO } from "./dto/myShare.dto";
|
||||||
import { ShareDTO } from "./dto/share.dto";
|
import { ShareDTO } from "./dto/share.dto";
|
||||||
@@ -27,10 +27,7 @@ import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
|||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
@Controller("shares")
|
@Controller("shares")
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(private shareService: ShareService) {}
|
||||||
private shareService: ShareService,
|
|
||||||
private config: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@@ -88,10 +85,20 @@ export class ShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(20, 5 * 60)
|
||||||
@UseGuards(ShareTokenSecurity)
|
@UseGuards(ShareTokenSecurity)
|
||||||
@Post(":id/token")
|
@Post(":id/token")
|
||||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
async getShareToken(
|
||||||
return this.shareService.getShareToken(id, body.password);
|
@Param("id") id: string,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
@Body() body: SharePasswordDto
|
||||||
|
) {
|
||||||
|
const token = await this.shareService.getShareToken(id, body.password);
|
||||||
|
response.cookie(`share_${id}_token`, token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,11 @@ export class ShareService {
|
|||||||
let expirationDate: Date;
|
let expirationDate: Date;
|
||||||
|
|
||||||
// If share is created by a reverse share token override the expiration date
|
// If share is created by a reverse share token override the expiration date
|
||||||
if (reverseShareToken) {
|
const reverseShare = await this.reverseShareService.getByToken(
|
||||||
const { shareExpiration } = await this.reverseShareService.getByToken(
|
reverseShareToken
|
||||||
reverseShareToken
|
);
|
||||||
);
|
if (reverseShare) {
|
||||||
|
expirationDate = reverseShare.shareExpiration;
|
||||||
expirationDate = shareExpiration;
|
|
||||||
} else {
|
} else {
|
||||||
// We have to add an exception for "never" (since moment won't like that)
|
// We have to add an exception for "never" (since moment won't like that)
|
||||||
if (share.expiration !== "never") {
|
if (share.expiration !== "never") {
|
||||||
@@ -84,12 +83,14 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (reverseShareToken) {
|
if (reverseShare) {
|
||||||
// Assign share to reverse share token
|
// Assign share to reverse share token
|
||||||
await this.prisma.reverseShare.update({
|
await this.prisma.reverseShare.update({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
data: {
|
data: {
|
||||||
shareId: share.id,
|
shares: {
|
||||||
|
connect: { id: shareTuple.id },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ export class ShareService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
share.reverseShare &&
|
share.reverseShare &&
|
||||||
this.config.get("SMTP_ENABLED") &&
|
this.config.get("smtp.enabled") &&
|
||||||
share.reverseShare.sendEmailNotification
|
share.reverseShare.sendEmailNotification
|
||||||
) {
|
) {
|
||||||
await this.emailService.sendMailToReverseShareCreator(
|
await this.emailService.sendMailToReverseShareCreator(
|
||||||
@@ -164,10 +165,10 @@ export class ShareService {
|
|||||||
// Check if any file is malicious with ClamAV
|
// Check if any file is malicious with ClamAV
|
||||||
this.clamScanService.checkAndRemove(share.id);
|
this.clamScanService.checkAndRemove(share.id);
|
||||||
|
|
||||||
if (reverseShareToken) {
|
if (share.reverseShare) {
|
||||||
await this.prisma.reverseShare.update({
|
await this.prisma.reverseShare.update({
|
||||||
where: { token: reverseShareToken },
|
where: { token: reverseShareToken },
|
||||||
data: { used: true },
|
data: { remainingUses: { decrement: 1 } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,12 +205,13 @@ export class ShareService {
|
|||||||
return sharesWithEmailRecipients;
|
return sharesWithEmailRecipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string): Promise<any> {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
|
security: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,8 +220,10 @@ export class ShareService {
|
|||||||
|
|
||||||
if (!share || !share.uploadLocked)
|
if (!share || !share.uploadLocked)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
return {
|
||||||
return share as any;
|
...share,
|
||||||
|
hasPassword: share.security?.password ? true : false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaData(id: string) {
|
async getMetaData(id: string) {
|
||||||
@@ -273,12 +277,20 @@ export class ShareService {
|
|||||||
if (
|
if (
|
||||||
share?.security?.password &&
|
share?.security?.password &&
|
||||||
!(await argon.verify(share.security.password, password))
|
!(await argon.verify(share.security.password, password))
|
||||||
)
|
) {
|
||||||
throw new ForbiddenException("Wrong password");
|
throw new ForbiddenException("Wrong password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Maximum views exceeded",
|
||||||
|
"share_max_views_exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const token = await this.generateShareToken(shareId);
|
const token = await this.generateShareToken(shareId);
|
||||||
await this.increaseViewCount(share);
|
await this.increaseViewCount(share);
|
||||||
return { token };
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateShareToken(shareId: string) {
|
async generateShareToken(shareId: string) {
|
||||||
@@ -291,7 +303,7 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -303,7 +315,7 @@ export class ShareService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const claims = this.jwtService.verify(token, {
|
const claims = this.jwtService.verify(token, {
|
||||||
secret: this.config.get("JWT_SECRET"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
// Ignore expiration if expiration is 0
|
// Ignore expiration if expiration is 0
|
||||||
ignoreExpiration: moment(expiration).isSame(0),
|
ignoreExpiration: moment(expiration).isSame(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Expose, plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { Allow } from "class-validator";
|
import { Allow, IsOptional, MinLength } from "class-validator";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class CreateUserDTO extends UserDTO {
|
export class CreateUserDTO extends UserDTO {
|
||||||
@Expose()
|
|
||||||
@Allow()
|
@Allow()
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@MinLength(8)
|
||||||
|
@IsOptional()
|
||||||
|
password: string;
|
||||||
|
|
||||||
from(partial: Partial<CreateUserDTO>) {
|
from(partial: Partial<CreateUserDTO>) {
|
||||||
return plainToClass(CreateUserDTO, partial, {
|
return plainToClass(CreateUserDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
export class PublicUserDTO extends PickType(UserDTO, ["username"] as const) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OmitType, PartialType } from "@nestjs/mapped-types";
|
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class UpdateOwnUserDTO extends PartialType(
|
export class UpdateOwnUserDTO extends PartialType(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
import { PartialType } from "@nestjs/swagger";
|
||||||
import { CreateUserDTO } from "./createUser.dto";
|
import { CreateUserDTO } from "./createUser.dto";
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { Response } from "express";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
@@ -40,7 +42,16 @@ export class UserController {
|
|||||||
|
|
||||||
@Delete("me")
|
@Delete("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async deleteCurrentUser(@GetUser() user: User) {
|
async deleteCurrentUser(
|
||||||
|
@GetUser() user: User,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
|
response.cookie("refresh_token", "", {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
return new UserDTO().from(await this.userService.delete(user.id));
|
return new UserDTO().from(await this.userService.delete(user.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { UserController } from "./user.controller";
|
import { UserController } from "./user.controller";
|
||||||
import { UserSevice } from "./user.service";
|
import { UserSevice } from "./user.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [EmailModule],
|
||||||
providers: [UserSevice],
|
providers: [UserSevice],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||||
import { UserDTO } from "./dto/user.dto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private emailService: EmailService
|
||||||
|
) {}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return await this.prisma.user.findMany();
|
return await this.prisma.user.findMany();
|
||||||
@@ -19,7 +22,17 @@ export class UserSevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateUserDTO) {
|
async create(dto: CreateUserDTO) {
|
||||||
const hash = await argon.hash(dto.password);
|
let hash: string;
|
||||||
|
|
||||||
|
// The password can be undefined if the user is invited by an admin
|
||||||
|
if (!dto.password) {
|
||||||
|
const randomPassword = crypto.randomUUID();
|
||||||
|
hash = await argon.hash(randomPassword);
|
||||||
|
this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||||
|
} else {
|
||||||
|
hash = await argon.hash(dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.user.create({
|
return await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
|
"_postman_id": "cd31bdf9-d558-42da-9231-154721476cd2",
|
||||||
"name": "Pingvin Share Testing",
|
"name": "Pingvin Share Testing",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
"_exporter_id": "17822132"
|
"_exporter_id": "17822132"
|
||||||
@@ -804,16 +804,6 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -853,16 +843,6 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -987,7 +967,8 @@
|
|||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"pm.collectionVariables.set(\"shareToken\", pm.response.json().token)"
|
"pm.collectionVariables.set(\"COOKIES\", `${pm.collectionVariables.get(\"COOKIES\")};${pm.response.headers.get(\"Set-Cookie\")}`)",
|
||||||
|
""
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
}
|
}
|
||||||
@@ -1041,8 +1022,6 @@
|
|||||||
" pm.expect(responseBody.files.length).be.equal(2)",
|
" pm.expect(responseBody.files.length).be.equal(2)",
|
||||||
"});",
|
"});",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
|
"pm.collectionVariables.set(\"fileId\", pm.response.json().files[0].id)"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
@@ -1051,13 +1030,7 @@
|
|||||||
],
|
],
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [
|
"header": [],
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId",
|
"raw": "{{API_URL}}/shares/:shareId",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -1077,88 +1050,6 @@
|
|||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Get file download url",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"let URL = require('url');",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Status code is 200\", () => {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Response body correct\", () => {",
|
|
||||||
" const responseBody = pm.response.json();",
|
|
||||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"fileDownloadPath\",path )"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
":fileId",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fileId",
|
|
||||||
"value": "{{fileId}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Get File",
|
"name": "Get File",
|
||||||
"event": [
|
"event": [
|
||||||
@@ -1174,97 +1065,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/{{fileDownloadPath}}",
|
"raw": "{{API_URL}}/shares/:shareId/files/{{fileId}}",
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"{{fileDownloadPath}}"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get zip download url",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"let URL = require('url');",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Status code is 200\", () => {",
|
|
||||||
" pm.response.to.have.status(200);",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"pm.test(\"Response body correct\", () => {",
|
|
||||||
" const responseBody = pm.response.json();",
|
|
||||||
" pm.expect(responseBody).to.have.property(\"url\")",
|
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
|
||||||
"});",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"const path = URL.parse(pm.response.json().url).path.replace(\"/api/\", \"\")",
|
|
||||||
"",
|
|
||||||
"pm.collectionVariables.set(\"zipDownloadPath\",path )"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "X-Share-Token",
|
|
||||||
"value": "{{shareToken}}",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
@@ -1272,8 +1077,7 @@
|
|||||||
"shares",
|
"shares",
|
||||||
":shareId",
|
":shareId",
|
||||||
"files",
|
"files",
|
||||||
"zip",
|
"{{fileId}}"
|
||||||
"download"
|
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
@@ -1306,64 +1110,16 @@
|
|||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/{{zipDownloadPath}}",
|
"raw": "{{API_URL}}/shares/:shareId/files/zip",
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"{{zipDownloadPath}}"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Negative",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Get share - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});"
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId",
|
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
"path": [
|
"path": [
|
||||||
"shares",
|
"shares",
|
||||||
":shareId"
|
":shareId",
|
||||||
|
"files",
|
||||||
|
"zip"
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
@@ -1374,7 +1130,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Negative",
|
||||||
|
"item": [
|
||||||
{
|
{
|
||||||
"name": "Get share token - Wrong password",
|
"name": "Get share token - Wrong password",
|
||||||
"event": [
|
"event": [
|
||||||
@@ -1468,128 +1229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"response": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get file download url - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/:fileId/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
":fileId",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fileId",
|
|
||||||
"value": "{{fileId}}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Get zip download url - No token",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"exec": [
|
|
||||||
"pm.test(\"Status code is 403\", () => {",
|
|
||||||
" pm.response.to.have.status(403);",
|
|
||||||
"});",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"type": "text/javascript"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"protocolProfileBehavior": {
|
|
||||||
"disableBodyPruning": true
|
|
||||||
},
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"body": {
|
|
||||||
"mode": "formdata",
|
|
||||||
"formdata": [
|
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "868c6a44-fb8c-4768-ad0d-ef22feebc8ea",
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files/zip/download",
|
|
||||||
"host": [
|
|
||||||
"{{API_URL}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"shares",
|
|
||||||
":shareId",
|
|
||||||
"files",
|
|
||||||
"zip",
|
|
||||||
"download"
|
|
||||||
],
|
|
||||||
"variable": [
|
|
||||||
{
|
|
||||||
"key": "shareId",
|
|
||||||
"value": "test-share"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/opt/app/backend/data"
|
- "./data:/opt/app/backend/data"
|
||||||
|
- "./data/images:/opt/app/frontend/public/img"
|
||||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# clamav:
|
# clamav:
|
||||||
|
|||||||
29
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.2",
|
"jose": "^4.11.2",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
@@ -2656,6 +2659,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mime-types": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/minimatch": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
@@ -5602,6 +5611,11 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
|
},
|
||||||
"node_modules/klona": {
|
"node_modules/klona": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
|
||||||
@@ -9913,6 +9927,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
||||||
},
|
},
|
||||||
|
"@types/mime-types": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/minimatch": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
@@ -12108,6 +12128,11 @@
|
|||||||
"object.assign": "^4.1.2"
|
"object.assign": "^4.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jwt-decode": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
|
},
|
||||||
"klona": {
|
"klona": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.11.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.2",
|
"jose": "^4.11.2",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/img/logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -8,55 +8,55 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "icons/icon-72x72.png",
|
"src": "img/icons/icon-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-96x96.png",
|
"src": "img/icons/icon-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-96x96.png",
|
"src": "img/icons/icon-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-128x128.png",
|
"src": "img/icons/icon-128x128.png",
|
||||||
"sizes": "128x128",
|
"sizes": "128x128",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-144x144.png",
|
"src": "img/icons/icon-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-152x152.png",
|
"src": "img/icons/icon-152x152.png",
|
||||||
"sizes": "152x152",
|
"sizes": "152x152",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-192x192.png",
|
"src": "img/icons/icon-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-384x384.png",
|
"src": "img/icons/icon-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "icons/icon-512x512.png",
|
"src": "img/icons/icon-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
@@ -1,34 +1,6 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
const Logo = ({ height, width }: { height: number; width: number }) => {
|
const Logo = ({ height, width }: { height: number; width: number }) => {
|
||||||
return (
|
return <Image src="/img/logo.png" alt="logo" height={height} width={width} />;
|
||||||
<svg
|
|
||||||
id="Layer_1"
|
|
||||||
data-name="Layer 1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 943.11 911.62"
|
|
||||||
height={height}
|
|
||||||
width={width}
|
|
||||||
>
|
|
||||||
<ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e" />
|
|
||||||
<ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f" />
|
|
||||||
<path
|
|
||||||
d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z"
|
|
||||||
fill="#37474f"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z"
|
|
||||||
fill="#fff"
|
|
||||||
/>
|
|
||||||
<polygon
|
|
||||||
points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68"
|
|
||||||
fill="#46509e"
|
|
||||||
/>
|
|
||||||
<ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f" />
|
|
||||||
<ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f" />
|
|
||||||
<path
|
|
||||||
d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z"
|
|
||||||
fill="#37474f"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
export default Logo;
|
export default Logo;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import useConfig from "../hooks/config.hook";
|
||||||
|
|
||||||
const Meta = ({
|
const Meta = ({
|
||||||
title,
|
title,
|
||||||
@@ -7,18 +8,21 @@ const Meta = ({
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
const metaTitle = `${title} - ${config.get("general.appName")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
{/* TODO: Doesn't work because script get only executed on client side */}
|
<title>{metaTitle}</title>
|
||||||
<title>{title} - Pingvin Share</title>
|
<meta name="og:title" content={metaTitle} />
|
||||||
<meta name="og:title" content={`${title} - Pingvin Share`} />
|
|
||||||
<meta
|
<meta
|
||||||
name="og:description"
|
name="og:description"
|
||||||
content={
|
content={
|
||||||
description ?? "An open-source and self-hosted sharing platform."
|
description ?? "An open-source and self-hosted sharing platform."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:title" content={`${title} - Pingvin Share`} />
|
<meta name="twitter:title" content={metaTitle} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const ThemeSwitcher = () => {
|
|||||||
);
|
);
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
const systemColorScheme = useColorScheme();
|
const systemColorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useForm, yupResolver } from "@mantine/form";
|
|||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useUser from "../../hooks/user.hook";
|
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Space,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import useConfig from "../../../hooks/config.hook";
|
|
||||||
import configService from "../../../services/config.service";
|
|
||||||
import {
|
|
||||||
AdminConfigGroupedByCategory,
|
|
||||||
UpdateConfig,
|
|
||||||
} from "../../../types/config.type";
|
|
||||||
import {
|
|
||||||
capitalizeFirstLetter,
|
|
||||||
configVariableToFriendlyName,
|
|
||||||
} from "../../../utils/string.util";
|
|
||||||
import toast from "../../../utils/toast.util";
|
|
||||||
|
|
||||||
import AdminConfigInput from "./AdminConfigInput";
|
|
||||||
import TestEmailButton from "./TestEmailButton";
|
|
||||||
|
|
||||||
const AdminConfigTable = () => {
|
|
||||||
const config = useConfig();
|
|
||||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
|
||||||
|
|
||||||
let updatedConfigVariables: UpdateConfig[] = [];
|
|
||||||
|
|
||||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
|
||||||
const index = updatedConfigVariables.findIndex(
|
|
||||||
(item) => item.key === configVariable.key
|
|
||||||
);
|
|
||||||
if (index > -1) {
|
|
||||||
updatedConfigVariables[index] = configVariable;
|
|
||||||
} else {
|
|
||||||
updatedConfigVariables.push(configVariable);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [configVariablesByCategory, setCofigVariablesByCategory] =
|
|
||||||
useState<AdminConfigGroupedByCategory>({});
|
|
||||||
|
|
||||||
const getConfigVariables = async () => {
|
|
||||||
await configService.listForAdmin().then((configVariables) => {
|
|
||||||
const configVariablesByCategory = configVariables.reduce(
|
|
||||||
(categories: any, item) => {
|
|
||||||
const category = categories[item.category] || [];
|
|
||||||
category.push(item);
|
|
||||||
categories[item.category] = category;
|
|
||||||
return categories;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
setCofigVariablesByCategory(configVariablesByCategory);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getConfigVariables();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box mb="lg">
|
|
||||||
{Object.entries(configVariablesByCategory).map(
|
|
||||||
([category, configVariables]) => {
|
|
||||||
return (
|
|
||||||
<Paper key={category} withBorder p="lg" mb="xl">
|
|
||||||
<Title mb="xs" order={3}>
|
|
||||||
{capitalizeFirstLetter(category)}
|
|
||||||
</Title>
|
|
||||||
{configVariables.map((configVariable) => (
|
|
||||||
<>
|
|
||||||
<Group position="apart">
|
|
||||||
<Stack
|
|
||||||
style={{ maxWidth: isMobile ? "100%" : "40%" }}
|
|
||||||
spacing={0}
|
|
||||||
>
|
|
||||||
<Title order={6}>
|
|
||||||
{configVariableToFriendlyName(configVariable.key)}
|
|
||||||
</Title>
|
|
||||||
<Text color="dimmed" size="sm" mb="xs">
|
|
||||||
{configVariable.description}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack></Stack>
|
|
||||||
<Box style={{ width: isMobile ? "100%" : "50%" }}>
|
|
||||||
<AdminConfigInput
|
|
||||||
key={configVariable.key}
|
|
||||||
updateConfigVariable={updateConfigVariable}
|
|
||||||
configVariable={configVariable}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Space h="lg" />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
{category == "smtp" && (
|
|
||||||
<Group position="right">
|
|
||||||
<TestEmailButton />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
<Group position="right">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (config.get("SETUP_STATUS") == "REGISTERED") {
|
|
||||||
configService
|
|
||||||
.updateMany(updatedConfigVariables)
|
|
||||||
.then(async () => {
|
|
||||||
await configService.finishSetup();
|
|
||||||
window.location.reload();
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
|
||||||
} else {
|
|
||||||
configService
|
|
||||||
.updateMany(updatedConfigVariables)
|
|
||||||
.then(() => {
|
|
||||||
updatedConfigVariables = [];
|
|
||||||
toast.success("Configurations updated successfully");
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminConfigTable;
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
Burger,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Header,
|
||||||
|
MediaQuery,
|
||||||
|
Text,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
import useConfig from "../../../hooks/config.hook";
|
||||||
|
import Logo from "../../Logo";
|
||||||
|
|
||||||
|
const ConfigurationHeader = ({
|
||||||
|
isMobileNavBarOpened,
|
||||||
|
setIsMobileNavBarOpened,
|
||||||
|
}: {
|
||||||
|
isMobileNavBarOpened: boolean;
|
||||||
|
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const config = useConfig();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
return (
|
||||||
|
<Header height={60} p="md">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||||||
|
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||||
|
<Burger
|
||||||
|
opened={isMobileNavBarOpened}
|
||||||
|
onClick={() => setIsMobileNavBarOpened((o) => !o)}
|
||||||
|
size="sm"
|
||||||
|
color={theme.colors.gray[6]}
|
||||||
|
mr="xl"
|
||||||
|
/>
|
||||||
|
</MediaQuery>
|
||||||
|
<Group position="apart" w="100%">
|
||||||
|
<Link href="/" passHref>
|
||||||
|
<Group>
|
||||||
|
<Logo height={35} width={35} />
|
||||||
|
<Text weight={600}>{config.get("general.appName")}</Text>
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
|
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
|
||||||
|
<Button variant="light" component={Link} href="/admin">
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</MediaQuery>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationHeader;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
createStyles,
|
||||||
|
Group,
|
||||||
|
MediaQuery,
|
||||||
|
Navbar,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ name: "General", icon: <TbSquare /> },
|
||||||
|
{ name: "Email", icon: <TbMail /> },
|
||||||
|
{ name: "Share", icon: <TbShare /> },
|
||||||
|
{ name: "SMTP", icon: <TbAt /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
activeLink: {
|
||||||
|
backgroundColor: theme.fn.variant({
|
||||||
|
variant: "light",
|
||||||
|
color: theme.primaryColor,
|
||||||
|
}).background,
|
||||||
|
color: theme.fn.variant({ variant: "light", color: theme.primaryColor })
|
||||||
|
.color,
|
||||||
|
|
||||||
|
borderRadius: theme.radius.sm,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ConfigurationNavBar = ({
|
||||||
|
categoryId,
|
||||||
|
isMobileNavBarOpened,
|
||||||
|
setIsMobileNavBarOpened,
|
||||||
|
}: {
|
||||||
|
categoryId: string;
|
||||||
|
isMobileNavBarOpened: boolean;
|
||||||
|
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
return (
|
||||||
|
<Navbar
|
||||||
|
p="md"
|
||||||
|
hiddenBreakpoint="sm"
|
||||||
|
hidden={!isMobileNavBarOpened}
|
||||||
|
width={{ sm: 200, lg: 300 }}
|
||||||
|
>
|
||||||
|
<Navbar.Section>
|
||||||
|
<Text size="xs" color="dimmed" mb="sm">
|
||||||
|
Configuration
|
||||||
|
</Text>
|
||||||
|
<Stack spacing="xs">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Box
|
||||||
|
p="xs"
|
||||||
|
component={Link}
|
||||||
|
onClick={() => setIsMobileNavBarOpened(false)}
|
||||||
|
className={
|
||||||
|
categoryId == category.name.toLowerCase()
|
||||||
|
? classes.activeLink
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
key={category.name}
|
||||||
|
href={`/admin/config/${category.name.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon
|
||||||
|
variant={
|
||||||
|
categoryId == category.name.toLowerCase()
|
||||||
|
? "filled"
|
||||||
|
: "light"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{category.icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm">{category.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Navbar.Section>
|
||||||
|
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||||
|
<Button mt="xl" variant="light" component={Link} href="/admin">
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
</MediaQuery>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationNavBar;
|
||||||
@@ -1,24 +1,69 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { useState } from "react";
|
||||||
import useUser from "../../../hooks/user.hook";
|
import useUser from "../../../hooks/user.hook";
|
||||||
import configService from "../../../services/config.service";
|
import configService from "../../../services/config.service";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const TestEmailButton = () => {
|
const TestEmailButton = ({
|
||||||
|
configVariablesChanged,
|
||||||
|
saveConfigVariables,
|
||||||
|
}: {
|
||||||
|
configVariablesChanged: boolean;
|
||||||
|
saveConfigVariables: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
await configService
|
||||||
|
.sendTestEmail(user!.email)
|
||||||
|
.then(() => toast.success("Email sent successfully"))
|
||||||
|
.catch((e) =>
|
||||||
|
modals.openModal({
|
||||||
|
title: "Failed to send email",
|
||||||
|
children: (
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Text size="sm">
|
||||||
|
While sending the test email, the following error occurred:
|
||||||
|
</Text>
|
||||||
|
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
configService
|
if (!configVariablesChanged) {
|
||||||
.sendTestEmail(user!.email)
|
setIsLoading(true);
|
||||||
.then(() => toast.success("Email sent successfully"))
|
await sendTestEmail();
|
||||||
.catch(() =>
|
setIsLoading(false);
|
||||||
toast.error(
|
} else {
|
||||||
"Failed to send the email. Please check the backend logs for more information."
|
modals.openConfirmModal({
|
||||||
)
|
title: "Save configuration",
|
||||||
)
|
children: (
|
||||||
}
|
<Text size="sm">
|
||||||
|
To continue you need to save the configuration first. Do you
|
||||||
|
want to save the configuration and send the test email?
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: "Save and send", cancel: "Cancel" },
|
||||||
|
onConfirm: async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await saveConfigVariables();
|
||||||
|
await sendTestEmail();
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Send test email
|
Send test email
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||||
import User from "../../types/user.type";
|
import User from "../../../types/user.type";
|
||||||
import showUpdateUserModal from "./showUpdateUserModal";
|
import showUpdateUserModal from "./showUpdateUserModal";
|
||||||
|
|
||||||
const ManageUserTable = ({
|
const ManageUserTable = ({
|
||||||
@@ -10,38 +10,44 @@ import {
|
|||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../../services/user.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showCreateUserModal = (
|
const showCreateUserModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
|
smtpEnabled: boolean,
|
||||||
getUsers: () => void
|
getUsers: () => void
|
||||||
) => {
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={5}>Create user</Title>,
|
title: <Title order={5}>Create user</Title>,
|
||||||
children: <Body modals={modals} getUsers={getUsers} />,
|
children: (
|
||||||
|
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
||||||
|
),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({
|
const Body = ({
|
||||||
modals,
|
modals,
|
||||||
|
smtpEnabled,
|
||||||
getUsers,
|
getUsers,
|
||||||
}: {
|
}: {
|
||||||
modals: ModalsContextProps;
|
modals: ModalsContextProps;
|
||||||
|
smtpEnabled: boolean;
|
||||||
getUsers: () => void;
|
getUsers: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: undefined,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
setPasswordManually: false,
|
||||||
},
|
},
|
||||||
validate: yupResolver(
|
validate: yupResolver(
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
email: yup.string().email(),
|
email: yup.string().email(),
|
||||||
username: yup.string().min(3),
|
username: yup.string().min(3),
|
||||||
password: yup.string().min(8),
|
password: yup.string().min(8).optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -62,14 +68,35 @@ const Body = ({
|
|||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label="Username" {...form.getInputProps("username")} />
|
<TextInput label="Username" {...form.getInputProps("username")} />
|
||||||
<TextInput label="Email" {...form.getInputProps("email")} />
|
<TextInput label="Email" {...form.getInputProps("email")} />
|
||||||
<PasswordInput
|
{smtpEnabled && (
|
||||||
label="New password"
|
<Switch
|
||||||
{...form.getInputProps("password")}
|
mt="xs"
|
||||||
/>
|
labelPosition="left"
|
||||||
|
label="Set password manually"
|
||||||
|
description="If not checked, the user will receive an email with a link to set their password."
|
||||||
|
{...form.getInputProps("setPasswordManually", {
|
||||||
|
type: "checkbox",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{form.values.setPasswordManually ||
|
||||||
|
(!smtpEnabled && (
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<Switch
|
<Switch
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
}}
|
||||||
mt="xs"
|
mt="xs"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
label="Admin privileges"
|
label="Admin privileges"
|
||||||
|
description="If checked, the user will be able to access the admin panel."
|
||||||
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../../services/user.service";
|
||||||
import User from "../../types/user.type";
|
import User from "../../../types/user.type";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showUpdateUserModal = (
|
const showUpdateUserModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
@@ -90,7 +90,7 @@ const Body = ({
|
|||||||
</form>
|
</form>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
||||||
<Accordion.Control>Change password</Accordion.Control>
|
<Accordion.Control px={0}>Change password</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<form
|
<form
|
||||||
onSubmit={passwordForm.onSubmit(async (values) => {
|
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Text,
|
Text,
|
||||||
@@ -11,15 +12,20 @@ import {
|
|||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { showNotification } from "@mantine/notifications";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TbInfoCircle } from "react-icons/tb";
|
import { TbInfoCircle } from "react-icons/tb";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const SignInForm = () => {
|
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
const { refreshUser } = useUser();
|
||||||
|
|
||||||
const [showTotp, setShowTotp] = React.useState(false);
|
const [showTotp, setShowTotp] = React.useState(false);
|
||||||
const [loginToken, setLoginToken] = React.useState("");
|
const [loginToken, setLoginToken] = React.useState("");
|
||||||
|
|
||||||
@@ -42,10 +48,10 @@ const SignInForm = () => {
|
|||||||
validate: yupResolver(validationSchema),
|
validate: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signIn = (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
authService
|
await authService
|
||||||
.signIn(email, password)
|
.signIn(email, password)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (response.data["loginToken"]) {
|
if (response.data["loginToken"]) {
|
||||||
// Prompt the user to enter their totp code
|
// Prompt the user to enter their totp code
|
||||||
setShowTotp(true);
|
setShowTotp(true);
|
||||||
@@ -58,7 +64,8 @@ const SignInForm = () => {
|
|||||||
});
|
});
|
||||||
setLoginToken(response.data["loginToken"]);
|
setLoginToken(response.data["loginToken"]);
|
||||||
} else {
|
} else {
|
||||||
window.location.replace("/");
|
await refreshUser();
|
||||||
|
router.replace(redirectPath);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
@@ -67,7 +74,10 @@ const SignInForm = () => {
|
|||||||
const signInTotp = (email: string, password: string, totp: string) => {
|
const signInTotp = (email: string, password: string, totp: string) => {
|
||||||
authService
|
authService
|
||||||
.signInTotp(email, password, totp, loginToken)
|
.signInTotp(email, password, totp, loginToken)
|
||||||
.then(() => window.location.replace("/"))
|
.then(async () => {
|
||||||
|
await refreshUser();
|
||||||
|
router.replace(redirectPath);
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error?.response?.data?.message == "Login token expired") {
|
if (error?.response?.data?.message == "Login token expired") {
|
||||||
toast.error("Login token expired");
|
toast.error("Login token expired");
|
||||||
@@ -82,16 +92,10 @@ const SignInForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title
|
<Title order={2} align="center" weight={900}>
|
||||||
align="center"
|
|
||||||
sx={(theme) => ({
|
|
||||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
|
||||||
fontWeight: 900,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Welcome back
|
Welcome back
|
||||||
</Title>
|
</Title>
|
||||||
{config.get("ALLOW_REGISTRATION") && (
|
{config.get("share.allowRegistration") && (
|
||||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
You don't have an account yet?{" "}
|
You don't have an account yet?{" "}
|
||||||
<Anchor component={Link} href={"signUp"} size="sm">
|
<Anchor component={Link} href={"signUp"} size="sm">
|
||||||
@@ -109,7 +113,7 @@ const SignInForm = () => {
|
|||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email or username"
|
label="Email or username"
|
||||||
placeholder="you@email.com"
|
placeholder="Your email or username"
|
||||||
{...form.getInputProps("emailOrUsername")}
|
{...form.getInputProps("emailOrUsername")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -127,6 +131,13 @@ const SignInForm = () => {
|
|||||||
{...form.getInputProps("totp")}
|
{...form.getInputProps("totp")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{config.get("smtp.enabled") && (
|
||||||
|
<Group position="right" mt="xs">
|
||||||
|
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||||
|
Forgot password?
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
<Button fullWidth mt="xl" type="submit">
|
<Button fullWidth mt="xl" type="submit">
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const SignUpForm = () => {
|
const SignUpForm = () => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
const { refreshUser } = useUser();
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email().required(),
|
||||||
@@ -33,25 +37,26 @@ const SignUpForm = () => {
|
|||||||
validate: yupResolver(validationSchema),
|
validate: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signUp = (email: string, username: string, password: string) => {
|
const signUp = async (email: string, username: string, password: string) => {
|
||||||
authService
|
await authService
|
||||||
.signUp(email, username, password)
|
.signUp(email, username, password)
|
||||||
.then(() => window.location.replace("/"))
|
.then(async () => {
|
||||||
|
const user = await refreshUser();
|
||||||
|
if (user?.isAdmin) {
|
||||||
|
router.replace("/admin/intro");
|
||||||
|
} else {
|
||||||
|
router.replace("/upload");
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title
|
<Title order={2} align="center" weight={900}>
|
||||||
align="center"
|
|
||||||
sx={(theme) => ({
|
|
||||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
|
||||||
fontWeight: 900,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Sign up
|
Sign up
|
||||||
</Title>
|
</Title>
|
||||||
{config.get("ALLOW_REGISTRATION") && (
|
{config.get("share.allowRegistration") && (
|
||||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
You have an account already?{" "}
|
You have an account already?{" "}
|
||||||
<Anchor component={Link} href={"signIn"} size="sm">
|
<Anchor component={Link} href={"signIn"} size="sm">
|
||||||
@@ -67,12 +72,12 @@ const SignUpForm = () => {
|
|||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Username"
|
label="Username"
|
||||||
placeholder="john.doe"
|
placeholder="Your username"
|
||||||
{...form.getInputProps("username")}
|
{...form.getInputProps("username")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="you@email.com"
|
placeholder="Your email"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
13
frontend/src/components/core/CenterLoader.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Center, Loader, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
const CenterLoader = () => {
|
||||||
|
return (
|
||||||
|
<Center style={{ height: "70vh" }}>
|
||||||
|
<Stack align="center" spacing={10}>
|
||||||
|
<Loader />
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CenterLoader;
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Box,
|
Box,
|
||||||
Burger,
|
Burger,
|
||||||
Container,
|
Container,
|
||||||
createStyles,
|
createStyles,
|
||||||
Group,
|
Group,
|
||||||
Header,
|
Header as MantineHeader,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -13,8 +12,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { TbPlus } from "react-icons/tb";
|
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
@@ -109,13 +108,20 @@ const useStyles = createStyles((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const NavBar = () => {
|
const Header = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const [opened, toggleOpened] = useDisclosure(false);
|
const [opened, toggleOpened] = useDisclosure(false);
|
||||||
|
|
||||||
const authenticatedLinks = [
|
const [currentRoute, setCurrentRoute] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentRoute(router.pathname);
|
||||||
|
}, [router.pathname]);
|
||||||
|
|
||||||
|
const authenticatedLinks: NavLink[] = [
|
||||||
{
|
{
|
||||||
link: "/upload",
|
link: "/upload",
|
||||||
label: "Upload",
|
label: "Upload",
|
||||||
@@ -128,32 +134,31 @@ const NavBar = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
let unauthenticatedLinks: NavLink[] = [
|
||||||
{
|
{
|
||||||
link: "/auth/signIn",
|
link: "/auth/signIn",
|
||||||
label: "Sign in",
|
label: "Sign in",
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
if (config.get("share.allowUnauthenticatedShares")) {
|
||||||
if (config.get("SHOW_HOME_PAGE"))
|
unauthenticatedLinks.unshift({
|
||||||
setUnauthenticatedLinks((array) => [
|
link: "/upload",
|
||||||
{
|
label: "Upload",
|
||||||
link: "/",
|
});
|
||||||
label: "Home",
|
}
|
||||||
},
|
|
||||||
...array,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (config.get("ALLOW_REGISTRATION"))
|
if (config.get("general.showHomePage"))
|
||||||
setUnauthenticatedLinks((array) => [
|
unauthenticatedLinks.unshift({
|
||||||
...array,
|
link: "/",
|
||||||
{
|
label: "Home",
|
||||||
link: "/auth/signUp",
|
});
|
||||||
label: "Sign up",
|
|
||||||
},
|
if (config.get("share.allowRegistration"))
|
||||||
]);
|
unauthenticatedLinks.push({
|
||||||
}, []);
|
link: "/auth/signUp",
|
||||||
|
label: "Sign up",
|
||||||
|
});
|
||||||
|
|
||||||
const { classes, cx } = useStyles();
|
const { classes, cx } = useStyles();
|
||||||
const items = (
|
const items = (
|
||||||
@@ -172,7 +177,7 @@ const NavBar = () => {
|
|||||||
href={link.link ?? ""}
|
href={link.link ?? ""}
|
||||||
onClick={() => toggleOpened.toggle()}
|
onClick={() => toggleOpened.toggle()}
|
||||||
className={cx(classes.link, {
|
className={cx(classes.link, {
|
||||||
[classes.linkActive]: window.location.pathname == link.link,
|
[classes.linkActive]: currentRoute == link.link,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
@@ -182,12 +187,12 @@ const NavBar = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
<MantineHeader height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||||
<Container className={classes.header}>
|
<Container className={classes.header}>
|
||||||
<Link href="/" passHref>
|
<Link href="/" passHref>
|
||||||
<Group>
|
<Group>
|
||||||
<Logo height={35} width={35} />
|
<Logo height={35} width={35} />
|
||||||
<Text weight={600}>Pingvin Share</Text>
|
<Text weight={600}>{config.get("general.appName")}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Link>
|
</Link>
|
||||||
<Group spacing={5} className={classes.links}>
|
<Group spacing={5} className={classes.links}>
|
||||||
@@ -207,8 +212,8 @@ const NavBar = () => {
|
|||||||
)}
|
)}
|
||||||
</Transition>
|
</Transition>
|
||||||
</Container>
|
</Container>
|
||||||
</Header>
|
</MantineHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NavBar;
|
export default Header;
|
||||||
@@ -1,18 +1,57 @@
|
|||||||
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
|
import {
|
||||||
import { TbCircleCheck, TbDownload } from "react-icons/tb";
|
ActionIcon,
|
||||||
import shareService from "../../services/share.service";
|
Group,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import mime from "mime-types";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import shareService from "../../services/share.service";
|
||||||
|
import { FileMetaData } from "../../types/File.type";
|
||||||
|
import { Share } from "../../types/share.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const FileList = ({
|
const FileList = ({
|
||||||
files,
|
files,
|
||||||
shareId,
|
share,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
files?: any[];
|
files?: FileMetaData[];
|
||||||
shareId: string;
|
share: Share;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
const config = useConfig();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const copyFileLink = (file: FileMetaData) => {
|
||||||
|
const link = `${config.get("general.appUrl")}/api/shares/${
|
||||||
|
share.id
|
||||||
|
}/files/${file.id}`;
|
||||||
|
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
clipboard.copy(link);
|
||||||
|
toast.success("Your file link was copied to the keyboard.");
|
||||||
|
} else {
|
||||||
|
modals.openModal({
|
||||||
|
title: "File link",
|
||||||
|
children: (
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput variant="filled" value={link} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -28,24 +67,35 @@ const FileList = ({
|
|||||||
: files!.map((file) => (
|
: files!.map((file) => (
|
||||||
<tr key={file.name}>
|
<tr key={file.name}>
|
||||||
<td>{file.name}</td>
|
<td>{file.name}</td>
|
||||||
<td>{byteToHumanSizeString(file.size)}</td>
|
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||||
<td>
|
<td>
|
||||||
{file.uploadingState ? (
|
<Group position="right">
|
||||||
file.uploadingState != "finished" ? (
|
{shareService.doesFileSupportPreview(file.name) && (
|
||||||
<Loader size={22} />
|
<ActionIcon
|
||||||
) : (
|
component={Link}
|
||||||
<TbCircleCheck color="green" size={22} />
|
href={`/share/${share.id}/preview/${
|
||||||
)
|
file.id
|
||||||
) : (
|
}?type=${mime.contentType(file.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
size={25}
|
||||||
|
>
|
||||||
|
<TbEye />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{!share.hasPassword && (
|
||||||
|
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||||
|
<TbLink />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={25}
|
size={25}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await shareService.downloadFile(shareId, file.id);
|
await shareService.downloadFile(share.id, file.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TbDownload />
|
<TbDownload />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
</Group>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const Body = ({
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
maxShareSize: 104857600,
|
maxShareSize: 104857600,
|
||||||
|
maxUseCount: 1,
|
||||||
sendEmailNotification: false,
|
sendEmailNotification: false,
|
||||||
expiration_num: 1,
|
expiration_num: 1,
|
||||||
expiration_unit: "-days",
|
expiration_unit: "-days",
|
||||||
@@ -60,6 +61,7 @@ const Body = ({
|
|||||||
.createReverseShare(
|
.createReverseShare(
|
||||||
values.expiration_num + values.expiration_unit,
|
values.expiration_num + values.expiration_unit,
|
||||||
values.maxShareSize,
|
values.maxShareSize,
|
||||||
|
values.maxUseCount,
|
||||||
values.sendEmailNotification
|
values.sendEmailNotification
|
||||||
)
|
)
|
||||||
.then(({ link }) => {
|
.then(({ link }) => {
|
||||||
@@ -132,6 +134,15 @@ const Body = ({
|
|||||||
value={form.values.maxShareSize}
|
value={form.values.maxShareSize}
|
||||||
onChange={(number) => form.setFieldValue("maxShareSize", number)}
|
onChange={(number) => form.setFieldValue("maxShareSize", number)}
|
||||||
/>
|
/>
|
||||||
|
<NumberInput
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
precision={0}
|
||||||
|
variant="filled"
|
||||||
|
label="Max use count"
|
||||||
|
description="The maximum number of times this reverse share link can be used"
|
||||||
|
{...form.getInputProps("maxUseCount")}
|
||||||
|
/>
|
||||||
{showSendEmailNotificationOption && (
|
{showSendEmailNotificationOption && (
|
||||||
<Switch
|
<Switch
|
||||||
mt="xs"
|
mt="xs"
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import configService from "../services/config.service";
|
import configService from "../services/config.service";
|
||||||
import Config from "../types/config.type";
|
import { ConfigHook } from "../types/config.type";
|
||||||
|
|
||||||
export const ConfigContext = createContext<Config[] | null>(null);
|
export const ConfigContext = createContext<ConfigHook>({
|
||||||
|
configVariables: [],
|
||||||
|
refresh: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
const useConfig = () => {
|
const useConfig = () => {
|
||||||
const configVariables = useContext(ConfigContext) as Config[];
|
const configContext = useContext(ConfigContext);
|
||||||
return {
|
return {
|
||||||
get: (key: string) => configService.get(key, configVariables),
|
get: (key: string) => configService.get(key, configContext.configVariables),
|
||||||
|
refresh: async () => configContext.refresh(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { UserHook } from "../types/user.type";
|
|||||||
|
|
||||||
export const UserContext = createContext<UserHook>({
|
export const UserContext = createContext<UserHook>({
|
||||||
user: null,
|
user: null,
|
||||||
setUser: () => {},
|
refreshUser: async () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const useUser = () => {
|
const useUser = () => {
|
||||||
|
|||||||
115
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import jwtDecode from "jwt-decode";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import configService from "./services/config.service";
|
||||||
|
|
||||||
|
// This middleware redirects based on different conditions:
|
||||||
|
// - Authentication state
|
||||||
|
// - Setup status
|
||||||
|
// - Admin privileges
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: "/((?!api|static|.*\\..*|_next).*)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const routes = {
|
||||||
|
unauthenticated: new Routes(["/auth/*", "/"]),
|
||||||
|
public: new Routes(["/share/*", "/upload/*"]),
|
||||||
|
admin: new Routes(["/admin/*"]),
|
||||||
|
account: new Routes(["/account*"]),
|
||||||
|
disabled: new Routes([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get config from backend
|
||||||
|
const config = await (
|
||||||
|
await fetch("http://localhost:8080/api/configs")
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const getConfig = (key: string) => {
|
||||||
|
return configService.get(key, config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = request.nextUrl.pathname;
|
||||||
|
let user: { isAdmin: boolean } | null = null;
|
||||||
|
const accessToken = request.cookies.get("access_token")?.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claims = jwtDecode<{ exp: number; isAdmin: boolean }>(
|
||||||
|
accessToken as string
|
||||||
|
);
|
||||||
|
if (claims.exp * 1000 > Date.now()) {
|
||||||
|
user = claims;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getConfig("share.allowRegistration")) {
|
||||||
|
routes.disabled.routes.push("/auth/signUp");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getConfig("share.allowUnauthenticatedShares")) {
|
||||||
|
routes.public.routes = ["*"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getConfig("smtp.enabled")) {
|
||||||
|
routes.disabled.routes.push("/auth/resetPassword*");
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const rules = [
|
||||||
|
// Disabled routes
|
||||||
|
{
|
||||||
|
condition: routes.disabled.contains(route),
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
// Authenticated state
|
||||||
|
{
|
||||||
|
condition: user && routes.unauthenticated.contains(route) && !getConfig("share.allowUnauthenticatedShares"),
|
||||||
|
path: "/upload",
|
||||||
|
},
|
||||||
|
// Unauthenticated state
|
||||||
|
{
|
||||||
|
condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route),
|
||||||
|
path: "/auth/signIn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: !user && routes.account.contains(route),
|
||||||
|
path: "/upload",
|
||||||
|
},
|
||||||
|
// Admin privileges
|
||||||
|
{
|
||||||
|
condition: routes.admin.contains(route) && !user?.isAdmin,
|
||||||
|
path: "/upload",
|
||||||
|
},
|
||||||
|
// Home page
|
||||||
|
{
|
||||||
|
condition: (!getConfig("general.showHomePage") || user) && route == "/",
|
||||||
|
path: "/upload",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.condition) {
|
||||||
|
let { path } = rule;
|
||||||
|
|
||||||
|
if (path == "/auth/signIn") {
|
||||||
|
path = path + "?redirect=" + encodeURIComponent(route);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(new URL(path, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper class to check if a route matches a list of routes
|
||||||
|
class Routes {
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
constructor(public routes: string[]) {}
|
||||||
|
|
||||||
|
contains(_route: string) {
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(_route))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,18 @@ import {
|
|||||||
ColorScheme,
|
ColorScheme,
|
||||||
ColorSchemeProvider,
|
ColorSchemeProvider,
|
||||||
Container,
|
Container,
|
||||||
LoadingOverlay,
|
|
||||||
MantineProvider,
|
MantineProvider,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useColorScheme } from "@mantine/hooks";
|
import { useColorScheme } from "@mantine/hooks";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { NotificationsProvider } from "@mantine/notifications";
|
import { NotificationsProvider } from "@mantine/notifications";
|
||||||
|
import axios from "axios";
|
||||||
|
import { getCookie, setCookie } from "cookies-next";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Header from "../components/navBar/NavBar";
|
import Header from "../components/header/Header";
|
||||||
import { ConfigContext } from "../hooks/config.hook";
|
import { ConfigContext } from "../hooks/config.hook";
|
||||||
import usePreferences from "../hooks/usePreferences";
|
import usePreferences from "../hooks/usePreferences";
|
||||||
import { UserContext } from "../hooks/user.hook";
|
import { UserContext } from "../hooks/user.hook";
|
||||||
@@ -22,57 +24,47 @@ import GlobalStyle from "../styles/global.style";
|
|||||||
import globalStyle from "../styles/mantine.style";
|
import globalStyle from "../styles/mantine.style";
|
||||||
import Config from "../types/config.type";
|
import Config from "../types/config.type";
|
||||||
import { CurrentUser } from "../types/user.type";
|
import { CurrentUser } from "../types/user.type";
|
||||||
import { GlobalLoadingContext } from "../utils/loading.util";
|
|
||||||
|
const excludeDefaultLayoutRoutes = ["/admin/config/[category]"];
|
||||||
|
|
||||||
function App({ Component, pageProps }: AppProps) {
|
function App({ Component, pageProps }: AppProps) {
|
||||||
const systemTheme = useColorScheme();
|
const systemTheme = useColorScheme(pageProps.colorScheme);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const preferences = usePreferences();
|
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
|
||||||
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
|
||||||
|
|
||||||
const getInitalData = async () => {
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme);
|
||||||
setIsLoading(true);
|
const preferences = usePreferences();
|
||||||
setConfigVariables(await configService.list());
|
|
||||||
await authService.refreshAccessToken();
|
const [user, setUser] = useState<CurrentUser | null>(pageProps.user);
|
||||||
setUser(await userService.getCurrentUser());
|
const [route, setRoute] = useState<string>(pageProps.route);
|
||||||
setIsLoading(false);
|
|
||||||
};
|
const [configVariables, setConfigVariables] = useState<Config[]>(
|
||||||
|
pageProps.configVariables
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoute(router.pathname);
|
||||||
|
}, [router.pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
|
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
|
||||||
getInitalData();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Redirect to setup page if setup is not completed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
const colorScheme =
|
||||||
configVariables &&
|
|
||||||
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
|
|
||||||
) {
|
|
||||||
const setupStatus = configVariables.filter(
|
|
||||||
(variable) => variable.key == "SETUP_STATUS"
|
|
||||||
)[0].value;
|
|
||||||
if (setupStatus == "STARTED") {
|
|
||||||
router.replace("/auth/signUp");
|
|
||||||
} else if (user && setupStatus == "REGISTERED") {
|
|
||||||
router.replace("/admin/setup");
|
|
||||||
} else if (setupStatus == "REGISTERED") {
|
|
||||||
router.replace("/auth/signIn");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [configVariables, router.asPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setColorScheme(
|
|
||||||
preferences.get("colorScheme") == "system"
|
preferences.get("colorScheme") == "system"
|
||||||
? systemTheme
|
? systemTheme
|
||||||
: preferences.get("colorScheme")
|
: preferences.get("colorScheme");
|
||||||
);
|
|
||||||
|
toggleColorScheme(colorScheme);
|
||||||
}, [systemTheme]);
|
}, [systemTheme]);
|
||||||
|
|
||||||
|
const toggleColorScheme = (value: ColorScheme) => {
|
||||||
|
setColorScheme(value ?? "light");
|
||||||
|
setCookie("mantine-color-scheme", value ?? "light", {
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
withGlobalStyles
|
withGlobalStyles
|
||||||
@@ -81,26 +73,41 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
>
|
>
|
||||||
<ColorSchemeProvider
|
<ColorSchemeProvider
|
||||||
colorScheme={colorScheme}
|
colorScheme={colorScheme}
|
||||||
toggleColorScheme={(value) => setColorScheme(value ?? "light")}
|
toggleColorScheme={toggleColorScheme}
|
||||||
>
|
>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
|
<ConfigContext.Provider
|
||||||
{isLoading ? (
|
value={{
|
||||||
<LoadingOverlay visible overlayOpacity={1} />
|
configVariables,
|
||||||
) : (
|
refresh: async () => {
|
||||||
<ConfigContext.Provider value={configVariables}>
|
setConfigVariables(await configService.list());
|
||||||
<UserContext.Provider value={{ user, setUser }}>
|
},
|
||||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
}}
|
||||||
|
>
|
||||||
|
<UserContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
refreshUser: async () => {
|
||||||
|
const user = await userService.getCurrentUser();
|
||||||
|
setUser(user);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{excludeDefaultLayoutRoutes.includes(route) ? (
|
||||||
|
<Component {...pageProps} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<Container>
|
<Container>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</Container>
|
</Container>
|
||||||
</UserContext.Provider>
|
</>
|
||||||
</ConfigContext.Provider>
|
)}
|
||||||
)}
|
</UserContext.Provider>
|
||||||
</GlobalLoadingContext.Provider>
|
</ConfigContext.Provider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</ColorSchemeProvider>
|
</ColorSchemeProvider>
|
||||||
@@ -108,4 +115,36 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user and config variables on server side when the first request is made
|
||||||
|
// These will get passed as a page prop to the App component and stored in the contexts
|
||||||
|
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
|
||||||
|
let pageProps: {
|
||||||
|
user?: CurrentUser;
|
||||||
|
configVariables?: Config[];
|
||||||
|
route?: string;
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
} = {
|
||||||
|
route: ctx.resolvedUrl,
|
||||||
|
colorScheme:
|
||||||
|
(getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light",
|
||||||
|
};
|
||||||
|
if (ctx.req) {
|
||||||
|
const cookieHeader = ctx.req.headers.cookie;
|
||||||
|
|
||||||
|
pageProps.user = await axios(`http://localhost:8080/api/users/me`, {
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
})
|
||||||
|
.then((res) => res.data)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
pageProps.configVariables = (
|
||||||
|
await axios(`http://localhost:8080/api/configs`)
|
||||||
|
).data;
|
||||||
|
|
||||||
|
pageProps.route = ctx.req.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageProps };
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ export default class _Document extends Document {
|
|||||||
<Html>
|
<Html>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-white-128x128.png" />
|
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
href="/img/icons/icon-white-128x128.png"
|
||||||
|
/>
|
||||||
|
|
||||||
<meta property="og:image" content="/img/opengraph-default.png" />
|
<meta property="og:image" content="/img/opengraph.png" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:image" content="/img/opengraph-default.png" />
|
<meta name="twitter:image" content="/img/opengraph.png" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<meta name="theme-color" content="#46509e" />
|
<meta name="theme-color" content="#46509e" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Tb2Fa } from "react-icons/tb";
|
import { Tb2Fa } from "react-icons/tb";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||||
@@ -25,9 +24,8 @@ import userService from "../../services/user.service";
|
|||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
const { user, setUser } = useUser();
|
const { user, refreshUser } = useUser();
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const accountForm = useForm({
|
const accountForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -83,13 +81,6 @@ const Account = () => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshUser = async () => setUser(await userService.getCurrentUser());
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
router.push("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title="My account" />
|
<Meta title="My account" />
|
||||||
@@ -171,7 +162,7 @@ const Account = () => {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="totp" pt="xs">
|
<Tabs.Panel value="totp" pt="xs">
|
||||||
{user.totpVerified ? (
|
{user!.totpVerified ? (
|
||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
onSubmit={disableTotpForm.onSubmit((values) => {
|
onSubmit={disableTotpForm.onSubmit((values) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
LoadingOverlay,
|
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
@@ -14,14 +14,13 @@ import {
|
|||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@mantine/hooks";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
|
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
|
||||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||||
|
import CenterLoader from "../../components/core/CenterLoader";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../components/Meta";
|
||||||
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
|
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
import { MyReverseShare } from "../../types/share.type";
|
import { MyReverseShare } from "../../types/share.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
@@ -30,10 +29,8 @@ import toast from "../../utils/toast.util";
|
|||||||
const MyShares = () => {
|
const MyShares = () => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const router = useRouter();
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
const { user } = useUser();
|
const config = useConfig();
|
||||||
|
|
||||||
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
|
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
|
||||||
|
|
||||||
@@ -47,154 +44,168 @@ const MyShares = () => {
|
|||||||
getReverseShares();
|
getReverseShares();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!user) {
|
if (!reverseShares) return <CenterLoader />;
|
||||||
router.replace("/");
|
return (
|
||||||
} else {
|
<>
|
||||||
if (!reverseShares) return <LoadingOverlay visible />;
|
<Meta title="My shares" />
|
||||||
return (
|
<Group position="apart" align="baseline" mb={20}>
|
||||||
<>
|
<Group align="center" spacing={3} mb={30}>
|
||||||
<Meta title="My shares" />
|
<Title order={3}>My reverse shares</Title>
|
||||||
<Group position="apart" align="baseline" mb={20}>
|
<Tooltip
|
||||||
<Group align="center" spacing={3} mb={30}>
|
position="bottom"
|
||||||
<Title order={3}>My reverse shares</Title>
|
multiline
|
||||||
<Tooltip
|
width={220}
|
||||||
position="bottom"
|
label="A reverse share allows you to generate a unique URL that allows external users to create a share."
|
||||||
multiline
|
events={{ hover: true, focus: false, touch: true }}
|
||||||
width={220}
|
|
||||||
label="A reverse share allows you to generate a unique URL for a single-use share for an external user."
|
|
||||||
events={{ hover: true, focus: false, touch: true }}
|
|
||||||
>
|
|
||||||
<ActionIcon>
|
|
||||||
<TbInfoCircle />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
showCreateReverseShareModal(
|
|
||||||
modals,
|
|
||||||
config.get("SMTP_ENABLED"),
|
|
||||||
getReverseShares
|
|
||||||
)
|
|
||||||
}
|
|
||||||
leftIcon={<TbPlus size={20} />}
|
|
||||||
>
|
>
|
||||||
Create
|
<ActionIcon>
|
||||||
</Button>
|
<TbInfoCircle />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
{reverseShares.length == 0 ? (
|
<Button
|
||||||
<Center style={{ height: "70vh" }}>
|
onClick={() =>
|
||||||
<Stack align="center" spacing={10}>
|
showCreateReverseShareModal(
|
||||||
<Title order={3}>It's empty here 👀</Title>
|
modals,
|
||||||
<Text>You don't have any reverse shares.</Text>
|
config.get("smtp.enabled"),
|
||||||
</Stack>
|
getReverseShares
|
||||||
</Center>
|
)
|
||||||
) : (
|
}
|
||||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
leftIcon={<TbPlus size={20} />}
|
||||||
<Table>
|
>
|
||||||
<thead>
|
Create
|
||||||
<tr>
|
</Button>
|
||||||
<th>Name</th>
|
</Group>
|
||||||
<th>Visitors</th>
|
{reverseShares.length == 0 ? (
|
||||||
<th>Max share size</th>
|
<Center style={{ height: "70vh" }}>
|
||||||
<th>Expires at</th>
|
<Stack align="center" spacing={10}>
|
||||||
<th></th>
|
<Title order={3}>It's empty here 👀</Title>
|
||||||
</tr>
|
<Text>You don't have any reverse shares.</Text>
|
||||||
</thead>
|
</Stack>
|
||||||
<tbody>
|
</Center>
|
||||||
{reverseShares.map((reverseShare) => (
|
) : (
|
||||||
<tr key={reverseShare.id}>
|
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||||
<td>
|
<Table>
|
||||||
{reverseShare.share ? (
|
<thead>
|
||||||
reverseShare.share?.id
|
<tr>
|
||||||
) : (
|
<th>Shares</th>
|
||||||
<Text color="dimmed">No share created yet</Text>
|
<th>Remaining uses</th>
|
||||||
)}
|
<th>Max share size</th>
|
||||||
</td>
|
<th>Expires at</th>
|
||||||
<td>{reverseShare.share?.views ?? "0"}</td>
|
<th></th>
|
||||||
<td>
|
</tr>
|
||||||
{byteToHumanSizeString(
|
</thead>
|
||||||
parseInt(reverseShare.maxShareSize)
|
<tbody>
|
||||||
)}
|
{reverseShares.map((reverseShare) => (
|
||||||
</td>
|
<tr key={reverseShare.id}>
|
||||||
<td>
|
<td style={{ width: 220 }}>
|
||||||
{moment(reverseShare.shareExpiration).unix() === 0
|
{reverseShare.shares.length == 0 ? (
|
||||||
? "Never"
|
<Text color="dimmed" size="sm">
|
||||||
: moment(reverseShare.shareExpiration).format("LLL")}
|
No shares created yet
|
||||||
</td>
|
</Text>
|
||||||
<td>
|
) : (
|
||||||
<Group position="right">
|
<Accordion>
|
||||||
{reverseShare.share && (
|
<Accordion.Item
|
||||||
<ActionIcon
|
value="customization"
|
||||||
color="victoria"
|
sx={{ borderBottom: "none" }}
|
||||||
variant="light"
|
|
||||||
size={25}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.isSecureContext) {
|
|
||||||
clipboard.copy(
|
|
||||||
`${config.get("APP_URL")}/share/${
|
|
||||||
reverseShare.share!.id
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
toast.success(
|
|
||||||
"The share link was copied to the keyboard."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showShareLinkModal(
|
|
||||||
modals,
|
|
||||||
reverseShare.share!.id,
|
|
||||||
config.get("APP_URL")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TbLink />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
<ActionIcon
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
size={25}
|
|
||||||
onClick={() => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: `Delete reverse share`,
|
|
||||||
children: (
|
|
||||||
<Text size="sm">
|
|
||||||
Do you really want to delete this reverse
|
|
||||||
share? If you do, the share will be deleted as
|
|
||||||
well.
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
confirmProps: {
|
|
||||||
color: "red",
|
|
||||||
},
|
|
||||||
labels: { confirm: "Confirm", cancel: "Cancel" },
|
|
||||||
onConfirm: () => {
|
|
||||||
shareService.removeReverseShare(
|
|
||||||
reverseShare.id
|
|
||||||
);
|
|
||||||
setReverseShares(
|
|
||||||
reverseShares.filter(
|
|
||||||
(item) => item.id !== reverseShare.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TbTrash />
|
<Accordion.Control p={0}>
|
||||||
</ActionIcon>
|
<Text size="sm">
|
||||||
</Group>
|
{`${reverseShare.shares.length} share${
|
||||||
</td>
|
reverseShare.shares.length > 1 ? "s" : ""
|
||||||
</tr>
|
}`}
|
||||||
))}
|
</Text>
|
||||||
</tbody>
|
</Accordion.Control>
|
||||||
</Table>
|
<Accordion.Panel>
|
||||||
</Box>
|
{reverseShare.shares.map((share) => (
|
||||||
)}
|
<Group key={share.id} mb={4}>
|
||||||
</>
|
<Text maw={120} truncate>
|
||||||
);
|
{share.id}
|
||||||
}
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
color="victoria"
|
||||||
|
variant="light"
|
||||||
|
size={25}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
clipboard.copy(
|
||||||
|
`${config.get(
|
||||||
|
"general.appUrl"
|
||||||
|
)}/share/${share.id}`
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
"The share link was copied to the keyboard."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showShareLinkModal(
|
||||||
|
modals,
|
||||||
|
share.id,
|
||||||
|
config.get("general.appUrl")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TbLink />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{reverseShare.remainingUses}</td>
|
||||||
|
<td>
|
||||||
|
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{moment(reverseShare.shareExpiration).unix() === 0
|
||||||
|
? "Never"
|
||||||
|
: moment(reverseShare.shareExpiration).format("LLL")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Group position="right">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
size={25}
|
||||||
|
onClick={() => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: `Delete reverse share`,
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
Do you really want to delete this reverse share?
|
||||||
|
If you do, the associated shares will be deleted
|
||||||
|
as well.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
confirmProps: {
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||||
|
onConfirm: () => {
|
||||||
|
shareService.removeReverseShare(reverseShare.id);
|
||||||
|
setReverseShares(
|
||||||
|
reverseShares.filter(
|
||||||
|
(item) => item.id !== reverseShare.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TbTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MyShares;
|
export default MyShares;
|
||||||
|
|||||||