Compare commits

..

33 Commits

Author SHA1 Message Date
Elias Schneider
22d81b2220 release: 0.11.0 2023-03-04 23:41:11 +01:00
Elias Schneider
0317f3a508 fix: frontend error when user deleted 2023-03-04 23:40:02 +01:00
Elias Schneider
fddad3ef70 feat: custom branding (#112)
* add first concept

* remove setup status

* split config page in multiple components

* add custom branding docs

* add test email button

* fix invalid email from header

* add migration

* mount images to host

* update docs

* remove unused endpoint

* run formatter
2023-03-04 23:29:00 +01:00
Elias Schneider
f9840505b8 feat: invite new user with email 2023-02-21 08:51:04 +01:00
Elias Schneider
759c55f625 docs: fix remove app before upgrading 2023-02-13 10:09:53 +01:00
Elias Schneider
edb511252f release: 0.10.2 2023-02-13 09:39:43 +01:00
Elias Schneider
c3af0fe097 fix: pdf preview tries to render on server 2023-02-13 09:39:27 +01:00
Elias Schneider
6419da07fb release: 0.10.1 2023-02-12 20:00:55 +01:00
Elias Schneider
7cd9dff637 fix: setup wizard doesn't redirect after completion 2023-02-12 20:00:35 +01:00
Elias Schneider
2a826f7941 docs: stand-alone installation start backend first 2023-02-12 19:04:12 +01:00
Elias Schneider
8720232755 chore: add question issue template 2023-02-12 14:40:10 +01:00
Elias Schneider
dc8cf3d5ca fix: non administrator user redirection error while setup isn't finished 2023-02-11 15:57:21 +01:00
Elias Schneider
979b882150 docs: add stand-alone installation guide 2023-02-10 14:59:19 +01:00
Elias Schneider
c55019f71b docs: improve contributing guideline 2023-02-10 14:22:32 +01:00
Elias Schneider
4c6ef52a17 release: 0.10.0 2023-02-10 11:47:29 +01:00
Elias Schneider
b9662701c4 fix: share creation without reverseShareToken 2023-02-10 11:47:17 +01:00
Elias Schneider
e3f88d0826 refactor(jobs): clear expired tokens and reverse shares 2023-02-10 11:29:51 +01:00
Elias Schneider
86a7379519 fix: delete all shares of reverse share 2023-02-10 11:15:23 +01:00
Elias Schneider
ccdf8ea3ae feat: allow multiple shares with one reverse share link 2023-02-10 11:10:07 +01:00
Elias Schneider
edc10b72b7 fix: share fails if a share was created with a reverse share link recently 2023-02-10 10:58:49 +01:00
Elias Schneider
5d1a7f0310 feat!: reset password with email 2023-02-09 18:17:53 +01:00
Elias Schneider
8ab359b71d docs(backend): add swagger documentation 2023-02-07 11:23:43 +01:00
Elias Schneider
38de022215 feat(frontend): server side rendering to improve performance 2023-02-07 10:21:25 +01:00
Elias Schneider
82f204e8a9 fix: invalid redirection after jwt expiry 2023-02-06 11:15:46 +01:00
Elias Schneider
4e840ecd29 refactor: handle authentication state in middleware 2023-02-04 18:12:49 +01:00
Elias Schneider
064ef38d78 fix: setup status doesn't change 2023-02-03 11:01:10 +01:00
Elias Schneider
b14e931d8d test: adapt tests to new features 2023-01-31 15:43:54 +01:00
Elias Schneider
3d5c919110 release: 0.9.0 2023-01-31 15:25:01 +01:00
Elias Schneider
008df06b5c feat: direct file link 2023-01-31 15:22:08 +01:00
Elias Schneider
cd9d828686 refactor: move guard checks to service 2023-01-31 13:53:23 +01:00
Elias Schneider
233c26e5cf fix: improve send test email UX 2023-01-31 13:16:11 +01:00
Elias Schneider
91a6b3f716 feat: file preview 2023-01-31 09:03:03 +01:00
Elias Schneider
0a2b7b1243 refactor: use cookie instead of local storage for share token 2023-01-26 21:18:22 +01:00
125 changed files with 2867 additions and 1866 deletions

17
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View 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
View File

@@ -39,4 +39,4 @@ yarn-error.log*
/data/ /data/
# Jetbrains specific (webstorm) # Jetbrains specific (webstorm)
.idea/**/** .idea/**/**

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}
} }

View File

@@ -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",

View File

@@ -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",

View File

@@ -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");

View File

@@ -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;

View File

@@ -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])
} }

View File

@@ -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();
}) })

View File

@@ -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,
}); });

View File

@@ -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],

View File

@@ -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) {

View File

@@ -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");

View File

@@ -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, [

View File

@@ -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";

View File

@@ -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;

View File

@@ -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) {}

View 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;
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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");
} }
} }
} }

View File

@@ -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"),
}); });
} }

View File

@@ -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")

View File

@@ -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 },
});
}
} }

View File

@@ -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,

View File

@@ -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);
}
}
} }

View File

@@ -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);
} }

View 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;
}
}
} }

View File

@@ -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);
}
}

View 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);
}
}
}

View File

@@ -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 {}

View File

@@ -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`);
} }
} }

View File

@@ -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();

View File

@@ -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;
} }

View File

@@ -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,
})
);
}
}

View 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,
})
);
}
}

View File

@@ -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)
); );
} }

View File

@@ -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 } });
} }
} }

View File

@@ -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 });
} }

View File

@@ -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");

View File

@@ -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;
} }
} }

View File

@@ -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 };
} }
} }

View File

@@ -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),
}); });

View File

@@ -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,

View File

@@ -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) {}

View File

@@ -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(

View File

@@ -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) {}

View File

@@ -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));
} }

View File

@@ -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],
}) })

View File

@@ -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: {

View File

@@ -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": []
} }
] ]
} }

View File

@@ -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:

View File

@@ -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",

View File

@@ -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",

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 944 B

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -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

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>
); );

View File

@@ -18,7 +18,6 @@ const ThemeSwitcher = () => {
); );
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
return ( return (
<Stack> <Stack>
<SegmentedControl <SegmentedControl

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 = ({

View File

@@ -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">

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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")}
/> />

View 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;

View File

@@ -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;

View File

@@ -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>
))} ))}

View File

@@ -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"

View File

@@ -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(),
}; };
}; };

View File

@@ -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
View 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;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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;

Some files were not shown because too many files have changed in this diff Show More