Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adf0f8d57e | ||
|
|
447c86f1c9 | ||
|
|
1466240461 | ||
|
|
348852cfa4 | ||
|
|
932496a121 | ||
|
|
0c7b2a8e70 | ||
|
|
1df5c7123e | ||
|
|
2dc0fc9332 | ||
|
|
98c0de78e8 | ||
|
|
5132d177b8 | ||
|
|
e5071cba12 | ||
|
|
b33c1d7f4b | ||
|
|
39a74510c1 | ||
|
|
b7db9b9b40 | ||
|
|
2ca0092b71 | ||
|
|
b4bf43910e | ||
|
|
90aa919694 | ||
|
|
f2e4019190 | ||
|
|
ffd4e43f11 | ||
|
|
0e5c673270 | ||
|
|
beece56327 | ||
|
|
a0d1d98e24 | ||
|
|
ca73ccf629 | ||
|
|
9f2097e788 | ||
|
|
2158df4228 | ||
|
|
37e765ddc7 | ||
|
|
a91c531642 | ||
|
|
5a7f7ca2f6 | ||
|
|
813ee4de2c | ||
|
|
b25c30d1ed | ||
|
|
c807d208d8 | ||
|
|
f82099f36e | ||
|
|
6345e21db9 | ||
|
|
f55aa80516 | ||
|
|
0ce8b528e1 | ||
|
|
8ff417a013 | ||
|
|
cb1a0d4090 | ||
|
|
753dbe83b7 | ||
|
|
0c2a62b0ca | ||
|
|
452c635933 | ||
|
|
0455ba1bc1 | ||
|
|
3ad6b03b6b | ||
|
|
91c3525b15 | ||
|
|
8403d7e14d | ||
|
|
8f71fd3435 | ||
|
|
155c743197 | ||
|
|
8b77e81d4c | ||
|
|
22d81b2220 | ||
|
|
0317f3a508 | ||
|
|
fddad3ef70 | ||
|
|
f9840505b8 | ||
|
|
759c55f625 | ||
|
|
edb511252f | ||
|
|
c3af0fe097 | ||
|
|
6419da07fb | ||
|
|
7cd9dff637 | ||
|
|
2a826f7941 | ||
|
|
8720232755 | ||
|
|
dc8cf3d5ca | ||
|
|
979b882150 | ||
|
|
c55019f71b |
17
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: ❓ Question
|
||||
description: "Submit a question"
|
||||
title: "❓ Question:"
|
||||
labels: [question]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "🙋♂️ Question"
|
||||
description: "A clear question. Please provide as much detail as possible."
|
||||
placeholder: "How do I ...?"
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, please check if the question hasn't been asked before.
|
||||
3
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-error.log*
|
||||
|
||||
# env file
|
||||
.env
|
||||
!/backend/prisma/.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -39,4 +40,4 @@ yarn-error.log*
|
||||
/data/
|
||||
|
||||
# Jetbrains specific (webstorm)
|
||||
.idea/**/**
|
||||
.idea/**/**
|
||||
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/backend/src/constants.ts
|
||||
115
CHANGELOG.md
@@ -1,3 +1,118 @@
|
||||
## [0.16.0](https://github.com/stonith404/pingvin-share/compare/v0.15.0...v0.16.0) (2023-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Adding more informations on My Shares page (table and modal) ([#174](https://github.com/stonith404/pingvin-share/issues/174)) ([1466240](https://github.com/stonith404/pingvin-share/commit/14662404614f15bc25384d924d8cb0458ab06cd8))
|
||||
* Adding the possibility of copying the link by clicking text and icons ([#171](https://github.com/stonith404/pingvin-share/issues/171)) ([348852c](https://github.com/stonith404/pingvin-share/commit/348852cfa4275f5c642669b43697f83c35333044))
|
||||
|
||||
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add env variables for port, database url and data dir ([98c0de7](https://github.com/stonith404/pingvin-share/commit/98c0de78e8a73e3e5bf0928226cfb8a024b566a1))
|
||||
* add healthcheck endpoint ([5132d17](https://github.com/stonith404/pingvin-share/commit/5132d177b8ab4e00a7e701e9956222fa2352d42c))
|
||||
* allow to configure clamav with environment variables ([1df5c71](https://github.com/stonith404/pingvin-share/commit/1df5c7123e4ca8695f4f1b7d49f46cdf147fb920))
|
||||
* configure ports, db url and api url with env variables ([e5071cb](https://github.com/stonith404/pingvin-share/commit/e5071cba1204093197b72e18d024b484e72e360a))
|
||||
|
||||
### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* boolean config variables can't be set to false ([39a7451](https://github.com/stonith404/pingvin-share/commit/39a74510c1f00466acaead39f7bee003b3db60d7))
|
||||
|
||||
## [0.14.0](https://github.com/stonith404/pingvin-share/compare/v0.13.1...v0.14.0) (2023-04-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **share, config:** more variables, placeholder and reset default ([#132](https://github.com/stonith404/pingvin-share/issues/132)) ([beece56](https://github.com/stonith404/pingvin-share/commit/beece56327da141c222fd9f5259697df6db9347a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bool config variable can't be changed ([0e5c673](https://github.com/stonith404/pingvin-share/commit/0e5c67327092e4751208e559a2b0d5ee2b91b6e3))
|
||||
|
||||
### [0.13.1](https://github.com/stonith404/pingvin-share/compare/v0.13.0...v0.13.1) (2023-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty file can't be uploaded in chrome ([9f2097e](https://github.com/stonith404/pingvin-share/commit/9f2097e788dfb79c2f95085025934c3134a3eb38))
|
||||
|
||||
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
|
||||
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
|
||||
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
|
||||
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
|
||||
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
|
||||
|
||||
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 48px icon does not update ([753dbe8](https://github.com/stonith404/pingvin-share/commit/753dbe83b770814115a2576c7a50e1bac9dc8ce1))
|
||||
|
||||
## [0.12.0](https://github.com/stonith404/pingvin-share/compare/v0.11.1...v0.12.0) (2023-03-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to change logo in frontend ([8403d7e](https://github.com/stonith404/pingvin-share/commit/8403d7e14ded801c3842a9b3fd87c3f6824c519e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* crypto is not defined ([8f71fd3](https://github.com/stonith404/pingvin-share/commit/8f71fd343506506532c1a24a4c66a16b1021705f))
|
||||
* home page shown even if disabled ([3ad6b03](https://github.com/stonith404/pingvin-share/commit/3ad6b03b6bd80168870049582683077b689fa548))
|
||||
|
||||
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* old config variable prevents to create a share ([8b77e81](https://github.com/stonith404/pingvin-share/commit/8b77e81d4c1b8a2bf798595f5a66079c40734e09))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
@@ -8,62 +12,55 @@ You've found a bug, have suggestion or something else, just create an issue on G
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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]
|
||||
$ git clone https://github.com/[your_username]/pingvin-share
|
||||
```
|
||||
|
||||
3. Work - commit - repeat
|
||||
|
||||
4. Before you push your changes, make sure you run the linter and format the code.
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
5. Push changes to GitHub
|
||||
4. Push changes to GitHub
|
||||
|
||||
```
|
||||
$ 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.
|
||||
7. Start a Pull Request
|
||||
Now submit the pull request and click on `Create pull request`.
|
||||
6. Start a Pull Request
|
||||
7. Now submit the pull request and click on `Create pull request`.
|
||||
8. Get a code review approval/reject
|
||||
|
||||
</details>
|
||||
|
||||
## Setup project
|
||||
|
||||
Pingvin Share consists of a frontend and a backend.
|
||||
|
||||
19
Dockerfile
@@ -1,26 +1,26 @@
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:18-slim AS frontend-dependencies
|
||||
FROM node:19-slim AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: on frontend change
|
||||
FROM node:18-slim AS frontend-builder
|
||||
FROM node:19-slim AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: on backend dependency change
|
||||
FROM node:18-slim AS backend-dependencies
|
||||
FROM node:19-slim AS backend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4:on backend change
|
||||
FROM node:18-slim AS backend-builder
|
||||
FROM node:19-slim AS backend-builder
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
@@ -29,16 +29,15 @@ RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:18-slim AS runner
|
||||
FROM node:19-slim AS runner
|
||||
ENV NODE_ENV=docker
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
RUN apt-get update && apt-get install -y curl openssl
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
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/static ./.next/static
|
||||
COPY --from=frontend-builder /opt/app/public/img /tmp/img
|
||||
|
||||
WORKDIR /opt/app/backend
|
||||
COPY --from=backend-builder /opt/app/node_modules ./node_modules
|
||||
@@ -48,4 +47,6 @@ COPY --from=backend-builder /opt/app/package.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
EXPOSE 3000
|
||||
CMD node frontend/server.js & cd backend && npm run prod
|
||||
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod
|
||||
119
README.md
@@ -1,39 +1,75 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- Create a share with files that you can access with a link
|
||||
- No file size limit, only your disk will be your limit
|
||||
- Set a share expiration
|
||||
- Optionally secure your share with a visitor limit and a password
|
||||
- Email recepients
|
||||
- ClamAV integration
|
||||
- Share files using a link
|
||||
- Unlimited file size (restricted only by disk space)
|
||||
- Set an expiration date for shares
|
||||
- Secure shares with visitor limits and passwords
|
||||
- Email recipients
|
||||
- Integration with ClamAV for security scans
|
||||
|
||||
## 🐧 Get to know Pingvin Share
|
||||
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ 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
|
||||
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/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Integrations
|
||||
|
||||
#### ClamAV
|
||||
#### 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.
|
||||
2. Docker will wait for ClamAV to start before starting Pingvin Share. This may take a minute or two.
|
||||
@@ -47,7 +83,64 @@ Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manu
|
||||
|
||||
### 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. Stop the running app
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
##### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
|
||||
##### Frontend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM node:18 AS deps
|
||||
WORKDIR /opt/app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
RUN npx prisma generate
|
||||
|
||||
|
||||
FROM node:18 As build
|
||||
WORKDIR /opt/app
|
||||
COPY . .
|
||||
COPY --from=deps /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM node:18 As runner
|
||||
WORKDIR /opt/app
|
||||
COPY --from=build /opt/app/node_modules ./node_modules
|
||||
COPY --from=build /opt/app/dist ./dist
|
||||
COPY --from=build /opt/app/prisma ./prisma
|
||||
COPY --from=deps /opt/app/package.json ./
|
||||
CMD npm run prod
|
||||
2052
backend/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.16.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
@@ -13,65 +13,68 @@
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^10.0.1",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/common": "^9.3.9",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
"@nestjs/core": "^9.3.9",
|
||||
"@nestjs/jwt": "^10.0.2",
|
||||
"@nestjs/passport": "^9.0.3",
|
||||
"@nestjs/platform-express": "^9.3.9",
|
||||
"@nestjs/schedule": "^2.2.0",
|
||||
"@nestjs/swagger": "^6.2.1",
|
||||
"@nestjs/throttler": "^3.1.0",
|
||||
"@prisma/client": "^4.8.1",
|
||||
"@nestjs/throttler": "^4.0.0",
|
||||
"@prisma/client": "^4.11.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"clamscan": "^2.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"class-validator": "^0.14.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nodemailer": "^6.9.0",
|
||||
"nodemailer": "^6.9.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^4.0.4",
|
||||
"rimraf": "^4.4.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.31.3",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.8",
|
||||
"@nestjs/cli": "^9.2.0",
|
||||
"@nestjs/schematics": "^9.0.4",
|
||||
"@nestjs/testing": "^9.2.1",
|
||||
"@nestjs/testing": "^9.3.9",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/clamscan": "^2.0.4",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.15.0",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
||||
"@typescript-eslint/parser": "^5.54.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.8.2",
|
||||
"prisma": "^4.9.0",
|
||||
"prettier": "^2.8.4",
|
||||
"prisma": "^4.11.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "^4.9.4",
|
||||
"typescript": "^4.9.5",
|
||||
"wait-on": "^7.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
2
backend/prisma/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
#This file is only used to set a default value for the database url
|
||||
DATABASE_URL="file:../data/pingvin-share.db"
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
|
||||
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("name", "category")
|
||||
);
|
||||
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
|
||||
|
||||
|
||||
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
|
||||
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
|
||||
|
||||
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT,
|
||||
"defaultValue" TEXT NOT NULL DEFAULT '',
|
||||
"description" TEXT NOT NULL,
|
||||
"obscured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("name", "category")
|
||||
);
|
||||
INSERT INTO "new_Config" ("category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||
DROP TABLE "Config";
|
||||
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -4,7 +4,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:../data/pingvin-share.db"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -131,13 +131,16 @@ model ShareSecurity {
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
key String @id
|
||||
type String
|
||||
value String
|
||||
description String
|
||||
category String
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
description String
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
|
||||
@@id([name, category])
|
||||
}
|
||||
|
||||
@@ -1,242 +1,251 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
const configVariables: ConfigVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
general: {
|
||||
appName: {
|
||||
description: "Name of the application",
|
||||
type: "string",
|
||||
defaultValue: "Pingvin Share",
|
||||
secret: false,
|
||||
},
|
||||
appUrl: {
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
defaultValue: "http://localhost:3000",
|
||||
|
||||
const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
{
|
||||
order: 0,
|
||||
key: "SETUP_STATUS",
|
||||
description: "Status of the setup wizard",
|
||||
type: "string",
|
||||
value: "STARTED", // STARTED, REGISTERED, FINISHED
|
||||
category: "internal",
|
||||
secret: false,
|
||||
locked: true,
|
||||
secret: false,
|
||||
},
|
||||
showHomePage: {
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
order: 0,
|
||||
key: "JWT_SECRET",
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).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,
|
||||
share: {
|
||||
allowRegistration: {
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
|
||||
key: "MAX_SHARE_SIZE",
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
value: "1073741824",
|
||||
category: "share",
|
||||
secret: false,
|
||||
},
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthenticatedShares: {
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
{
|
||||
order: 6,
|
||||
key: "ENABLE_SHARE_EMAIL_RECIPIENTS",
|
||||
description:
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
category: "email",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
||||
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 🐧",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
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: 10,
|
||||
key: "REVERSE_SHARE_EMAIL_MESSAGE",
|
||||
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 🐧",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
key: "RESET_PASSWORD_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
value: "Pingvin Share password reset",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 12,
|
||||
key: "RESET_PASSWORD_EMAIL_MESSAGE",
|
||||
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 🐧",
|
||||
category: "email",
|
||||
},
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
defaultValue: "1073741824",
|
||||
|
||||
{
|
||||
order: 13,
|
||||
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,
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
order: 14,
|
||||
key: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 15,
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "0",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 16,
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 17,
|
||||
key: "SMTP_USERNAME",
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 18,
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
obscured: true,
|
||||
category: "smtp",
|
||||
},
|
||||
];
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
description:
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
secret: false,
|
||||
},
|
||||
shareRecipientsSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
defaultValue: "Files shared with you",
|
||||
},
|
||||
shareRecipientsMessage: {
|
||||
description:
|
||||
"Message which gets sent to the share recipients.\n\nAvailable variables:\n{creator} - The username of the creator of the share\n{shareUrl} - The URL of the share\n{desc} - The description of the share\n{expires} - The expiration date of the share\n\nVariables will be replaced with the actual values.",
|
||||
type: "text",
|
||||
defaultValue:
|
||||
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
reverseShareSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
type: "string",
|
||||
defaultValue: "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",
|
||||
defaultValue:
|
||||
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
resetPasswordSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
defaultValue: "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",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when an admin invites an user.",
|
||||
type: "string",
|
||||
defaultValue: "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",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
||||
},
|
||||
},
|
||||
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",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
host: {
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
port: {
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
email: {
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
username: {
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
password: {
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
for (const variable of configVariables) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { key: variable.key },
|
||||
});
|
||||
type ConfigVariables = {
|
||||
[category: string]: {
|
||||
[variable: string]: Omit<
|
||||
Prisma.ConfigCreateInput,
|
||||
"name" | "category" | "order"
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
// Create a new config variable if it doesn't exist
|
||||
if (!existingConfigVariable) {
|
||||
await prisma.config.create({
|
||||
data: variable,
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
"file:../data/pingvin-share.db?connection_limit=1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function seedConfigVariables() {
|
||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||
configVariables
|
||||
)) {
|
||||
let order = 0;
|
||||
for (const [name, properties] of Object.entries(
|
||||
configVariablesOfCategory
|
||||
)) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { name_category: { name, category } },
|
||||
});
|
||||
|
||||
// Create a new config variable if it doesn't exist
|
||||
if (!existingConfigVariable) {
|
||||
await prisma.config.create({
|
||||
data: {
|
||||
order,
|
||||
name,
|
||||
...properties,
|
||||
category,
|
||||
},
|
||||
});
|
||||
}
|
||||
order++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 configVariableFromDatabase of configVariablesFromDatabase) {
|
||||
const configVariable = configVariables.find(
|
||||
(v) => v.key == configVariableFromDatabase.key
|
||||
);
|
||||
for (const existingConfigVariable of existingConfigVariables) {
|
||||
const configVariable =
|
||||
configVariables[existingConfigVariable.category]?.[
|
||||
existingConfigVariable.name
|
||||
];
|
||||
if (!configVariable) {
|
||||
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
|
||||
} else if (
|
||||
JSON.stringify({
|
||||
...configVariable,
|
||||
key: configVariableFromDatabase.key,
|
||||
value: configVariableFromDatabase.value,
|
||||
}) != JSON.stringify(configVariableFromDatabase)
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
}) != JSON.stringify(existingConfigVariable)
|
||||
) {
|
||||
await prisma.config.update({
|
||||
where: { key: configVariableFromDatabase.key },
|
||||
where: {
|
||||
name_category: {
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...configVariable,
|
||||
key: configVariableFromDatabase.key,
|
||||
value: configVariableFromDatabase.value,
|
||||
name: existingConfigVariable.name,
|
||||
category: existingConfigVariable.category,
|
||||
value: existingConfigVariable.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
main()
|
||||
|
||||
seedConfigVariables()
|
||||
.then(() => migrateConfigVariables())
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ export class AuthController {
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
) {
|
||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||
if (!this.config.get("share.allowRegistration"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
|
||||
const result = await this.authService.signUp(dto);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
@@ -25,7 +25,7 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
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);
|
||||
try {
|
||||
@@ -38,10 +38,6 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
if (isFirstUser) {
|
||||
await this.config.changeSetupStatus("REGISTERED");
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
);
|
||||
@@ -161,7 +157,7 @@ export class AuthService {
|
||||
},
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
@@ -42,7 +44,7 @@ export class AuthTotpService {
|
||||
throw new UnauthorizedException("Invalid login token");
|
||||
|
||||
if (token.expiresAt < new Date())
|
||||
throw new UnauthorizedException("Login token expired");
|
||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||
|
||||
// Check the TOTP code
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
@@ -95,7 +97,7 @@ export class AuthTotpService {
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
"pingvin-share",
|
||||
this.config.get("general.appName"),
|
||||
secret
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") {
|
||||
try {
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch {
|
||||
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
||||
return this.config.get("share.allowUnauthenticatedShares");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import { PrismaService } from "src/prisma/prisma.service";
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
config.get("JWT_SECRET");
|
||||
config.get("internal.jwtSecret");
|
||||
super({
|
||||
jwtFromRequest: JwtStrategy.extractJWT,
|
||||
secretOrKey: config.get("JWT_SECRET"),
|
||||
secretOrKey: config.get("internal.jwtSecret"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as NodeClam from "clamscan";
|
||||
import * as fs from "fs";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
const clamscanConfig = {
|
||||
clamdscan: {
|
||||
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
||||
port: 3310,
|
||||
host: CLAMAV_HOST,
|
||||
port: CLAMAV_PORT,
|
||||
localFallback: false,
|
||||
},
|
||||
preference: "clamdscan",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ClamScanService {
|
||||
private readonly logger = new Logger(ClamScanService.name);
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
@@ -23,11 +25,11 @@ export class ClamScanService {
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
.init(clamscanConfig)
|
||||
.then((res) => {
|
||||
console.log("ClamAV is active");
|
||||
this.logger.log("ClamAV is active");
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
this.logger.log("ClamAV is not active");
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -39,14 +41,14 @@ export class ClamScanService {
|
||||
const infectedFiles = [];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(`./data/uploads/shares/${shareId}`)
|
||||
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
|
||||
.filter((file) => file != "archive.zip");
|
||||
|
||||
for (const fileId of files) {
|
||||
const { isInfected } = await clamScan
|
||||
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
||||
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
this.logger.log("ClamAV is not active");
|
||||
return { isInfected: false };
|
||||
});
|
||||
|
||||
@@ -78,7 +80,7 @@ export class ClamScanService {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
this.logger.warn(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
FileTypeValidator,
|
||||
Get,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { SkipThrottle } from "@nestjs/throttler";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
@@ -8,11 +21,13 @@ import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
||||
import { ConfigDTO } from "./dto/config.dto";
|
||||
import { TestEmailDTO } from "./dto/testEmail.dto";
|
||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||
import { LogoService } from "./logo.service";
|
||||
|
||||
@Controller("configs")
|
||||
export class ConfigController {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private logoService: LogoService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
@@ -22,24 +37,20 @@ export class ConfigController {
|
||||
return new ConfigDTO().fromList(await this.configService.list());
|
||||
}
|
||||
|
||||
@Get("admin")
|
||||
@Get("admin/:category")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async listForAdmin() {
|
||||
async getByCategory(@Param("category") category: string) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.listForAdmin()
|
||||
await this.configService.getByCategory(category)
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("admin")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||
await this.configService.updateMany(data);
|
||||
}
|
||||
|
||||
@Post("admin/finishSetup")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async finishSetup() {
|
||||
return await this.configService.changeSetupStatus("FINISHED");
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.updateMany(data)
|
||||
);
|
||||
}
|
||||
|
||||
@Post("admin/testEmail")
|
||||
@@ -47,4 +58,18 @@ export class ConfigController {
|
||||
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||
await this.emailService.sendTestMail(email);
|
||||
}
|
||||
|
||||
@Post("admin/logo")
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async uploadLogo(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File
|
||||
) {
|
||||
return await this.logoService.create(file.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EmailModule } from "src/email/email.module";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { LogoService } from "./logo.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -16,6 +17,7 @@ import { ConfigService } from "./config.service";
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
LogoService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
exports: [ConfigService],
|
||||
|
||||
@@ -14,49 +14,76 @@ export class ConfigService {
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
get(key: string): any {
|
||||
get(key: `${string}.${string}`): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => variable.key == key
|
||||
(variable) => `${variable.category}.${variable.name}` == key
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "boolean") return value == "true";
|
||||
if (configVariable.type == "string" || configVariable.type == "text")
|
||||
return configVariable.value;
|
||||
return value;
|
||||
}
|
||||
|
||||
async listForAdmin() {
|
||||
return await this.prisma.config.findMany({
|
||||
async getByCategory(category: string) {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
where: { locked: { equals: false } },
|
||||
where: { category, locked: { equals: false } },
|
||||
});
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
return await this.prisma.config.findMany({
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
where: { secret: { equals: false } },
|
||||
});
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async updateMany(data: { key: string; value: string | number | boolean }[]) {
|
||||
const response: Config[] = [];
|
||||
|
||||
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) {
|
||||
const configVariable = await this.prisma.config.findUnique({
|
||||
where: { key },
|
||||
where: {
|
||||
name_category: {
|
||||
category: key.split(".")[0],
|
||||
name: key.split(".")[1],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!configVariable || configVariable.locked)
|
||||
throw new NotFoundException("Config variable not found");
|
||||
|
||||
if (
|
||||
if (value === "") {
|
||||
value = null;
|
||||
} else if (
|
||||
typeof value != configVariable.type &&
|
||||
typeof value == "string" &&
|
||||
configVariable.type != "text"
|
||||
@@ -67,19 +94,13 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: { key },
|
||||
data: { value: value.toString() },
|
||||
});
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
|
||||
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: { key: "SETUP_STATUS" },
|
||||
data: { value: status },
|
||||
where: {
|
||||
name_category: {
|
||||
category: key.split(".")[0],
|
||||
name: key.split(".")[1],
|
||||
},
|
||||
},
|
||||
data: { value: value === null ? null : value.toString() },
|
||||
});
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Expose, plainToClass } from "class-transformer";
|
||||
import { ConfigDTO } from "./config.dto";
|
||||
|
||||
export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
secret: boolean;
|
||||
|
||||
@Expose()
|
||||
defaultValue: string;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -14,9 +20,6 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
|
||||
@Expose()
|
||||
category: string;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { IsNotEmpty, IsString, ValidateIf } from "class-validator";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
class UpdateConfigDTO {
|
||||
@IsString()
|
||||
key: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@ValidateIf((dto) => dto.value !== "")
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
|
||||
32
backend/src/config/logo.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as fs from "fs";
|
||||
import * as sharp from "sharp";
|
||||
|
||||
const IMAGES_PATH = "../frontend/public/img";
|
||||
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
async create(file: Buffer) {
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||
this.createFavicon(file);
|
||||
this.createPWAIcons(file);
|
||||
}
|
||||
|
||||
async createFavicon(file: Buffer) {
|
||||
const resized = await sharp(file).resize(16).toBuffer();
|
||||
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
|
||||
}
|
||||
|
||||
async createPWAIcons(file: Buffer) {
|
||||
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
|
||||
|
||||
for (const size of sizes) {
|
||||
const resized = await sharp(file).resize(size).toBuffer();
|
||||
fs.promises.writeFile(
|
||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||
resized,
|
||||
"binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
backend/src/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`
|
||||
export const DATABASE_URL = process.env.DATABASE_URL || "file:../data/pingvin-share.db?connection_limit=1";
|
||||
export const CLAMAV_HOST = process.env.CLAMAV_HOST || (process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||
@@ -1,89 +1,133 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as moment from "moment";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
|
||||
getTransporter() {
|
||||
if (!this.config.get("SMTP_ENABLED"))
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
throw new InternalServerErrorException("SMTP is disabled");
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: this.config.get("SMTP_HOST"),
|
||||
port: parseInt(this.config.get("SMTP_PORT")),
|
||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||
host: this.config.get("smtp.host"),
|
||||
port: this.config.get("smtp.port"),
|
||||
secure: this.config.get("smtp.port") == 465,
|
||||
auth: {
|
||||
user: this.config.get("SMTP_USERNAME"),
|
||||
pass: this.config.get("SMTP_PASSWORD"),
|
||||
user: this.config.get("smtp.username"),
|
||||
pass: this.config.get("smtp.password"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendMailToShareRecepients(
|
||||
private async sendMail(email: string, subject: string, text: string) {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
throw new InternalServerErrorException("Failed to send email");
|
||||
});
|
||||
}
|
||||
|
||||
async sendMailToShareRecipients(
|
||||
recipientEmail: string,
|
||||
shareId: string,
|
||||
creator?: User
|
||||
creator?: User,
|
||||
description?: string,
|
||||
expiration?: Date
|
||||
) {
|
||||
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS"))
|
||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||
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({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE")
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.shareRecipientsSubject"),
|
||||
this.config
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{desc}", description ?? "No description")
|
||||
.replaceAll(
|
||||
"{expires}",
|
||||
moment(expiration).unix() != 0
|
||||
? moment(expiration).fromNow()
|
||||
: "in: never"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("REVERSE_SHARE_EMAIL_MESSAGE")
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.reverseShareSubject"),
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"APP_URL"
|
||||
"general.appUrl"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("RESET_PASSWORD_EMAIL_MESSAGE")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.resetPasswordSubject"),
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl)
|
||||
);
|
||||
}
|
||||
|
||||
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.inviteSubject"),
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password)
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
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) => {
|
||||
this.logger.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export class FileController {
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
const data = body.toString().split(",")[1];
|
||||
// Data can be empty if the file is empty
|
||||
const data = body.toString().split(",")[1] ?? "";
|
||||
|
||||
return await this.fileService.create(
|
||||
data,
|
||||
@@ -51,7 +52,7 @@ export class FileController {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
@@ -39,7 +40,7 @@ export class FileService {
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = fs.statSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
@@ -67,7 +68,7 @@ export class FileService {
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
|
||||
if (
|
||||
shareSizeSum > this.config.get("MAX_SHARE_SIZE") ||
|
||||
shareSizeSum > this.config.get("share.maxSize") ||
|
||||
(share.reverseShare?.maxShareSize &&
|
||||
shareSizeSum > parseInt(share.reverseShare.maxShareSize))
|
||||
) {
|
||||
@@ -78,18 +79,18 @@ export class FileService {
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
fs.renameSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||
`./data/uploads/shares/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
);
|
||||
const fileSize = fs.statSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
@@ -111,9 +112,7 @@ export class FileService {
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = fs.createReadStream(
|
||||
`./data/uploads/shares/${shareId}/${fileId}`
|
||||
);
|
||||
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
@@ -126,13 +125,13 @@ export class FileService {
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`./data/uploads/shares/${shareId}`, {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
||||
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
private readonly logger = new Logger(JobsService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
@@ -34,8 +37,9 @@ export class JobsService {
|
||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||
}
|
||||
|
||||
if (expiredShares.length > 0)
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
if (expiredShares.length > 0) {
|
||||
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -50,10 +54,11 @@ export class JobsService {
|
||||
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||
}
|
||||
|
||||
if (expiredReverseShares.length > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
if (expiredReverseShares.length > 0) {
|
||||
this.logger.log(
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
@@ -61,31 +66,31 @@ export class JobsService {
|
||||
let filesDeleted = 0;
|
||||
|
||||
const shareDirectories = fs
|
||||
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
||||
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
|
||||
for (const shareDirectory of shareDirectories) {
|
||||
const temporaryFiles = fs
|
||||
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
||||
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
|
||||
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||
|
||||
for (const file of temporaryFiles) {
|
||||
const stats = fs.statSync(
|
||||
`./data/uploads/shares/${shareDirectory}/${file}`
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
|
||||
);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
.isBefore(moment());
|
||||
|
||||
if (isOlderThanOneDay) {
|
||||
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
||||
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${filesDeleted} temporary files`);
|
||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -107,7 +112,8 @@ export class JobsService {
|
||||
const deletedTokensCount =
|
||||
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||
|
||||
if (deletedTokensCount > 0)
|
||||
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
|
||||
if (deletedTokensCount > 0) {
|
||||
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as bodyParser from "body-parser";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
@@ -16,7 +17,9 @@ async function bootstrap() {
|
||||
app.use(cookieParser());
|
||||
app.set("trust proxy", true);
|
||||
|
||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||
await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
@@ -30,6 +33,6 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(8080);
|
||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
@@ -7,7 +8,7 @@ export class PrismaService extends PrismaClient {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
url: "file:../data/pingvin-share.db?connection_limit=1",
|
||||
url: DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ReverseShareController {
|
||||
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class ReverseShareService {
|
||||
)
|
||||
.toDate();
|
||||
|
||||
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE");
|
||||
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||
|
||||
if (globalMaxShareSize < data.maxShareSize)
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
import {FileDTO} from "../../file/dto/file.dto";
|
||||
import {OmitType} from "@nestjs/swagger";
|
||||
|
||||
export class MyShareDTO extends ShareDTO {
|
||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
"from",
|
||||
"fromList",
|
||||
] as const) {
|
||||
@Expose()
|
||||
views: number;
|
||||
|
||||
@@ -11,6 +17,10 @@ export class MyShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
recipients: string[];
|
||||
|
||||
@Expose()
|
||||
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
|
||||
files: Omit<FileDTO, "share" | "from">[];
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
@@ -20,4 +30,4 @@ export class MyShareDTO extends ShareDTO {
|
||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -65,7 +66,7 @@ export class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -99,7 +100,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
const path = `./data/uploads/shares/${shareId}`;
|
||||
const path = `${SHARE_DIRECTORY}/${shareId}`;
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
const archive = archiver("zip", {
|
||||
@@ -142,18 +143,20 @@ export class ShareService {
|
||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
||||
);
|
||||
|
||||
// Send email for each recepient
|
||||
for (const recepient of share.recipients) {
|
||||
await this.emailService.sendMailToShareRecepients(
|
||||
recepient.email,
|
||||
// Send email for each recipient
|
||||
for (const recipient of share.recipients) {
|
||||
await this.emailService.sendMailToShareRecipients(
|
||||
recipient.email,
|
||||
share.id,
|
||||
share.creator
|
||||
share.creator,
|
||||
share.description,
|
||||
share.expiration
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
share.reverseShare &&
|
||||
this.config.get("SMTP_ENABLED") &&
|
||||
this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
@@ -163,7 +166,7 @@ export class ShareService {
|
||||
}
|
||||
|
||||
// Check if any file is malicious with ClamAV
|
||||
this.clamScanService.checkAndRemove(share.id);
|
||||
void this.clamScanService.checkAndRemove(share.id);
|
||||
|
||||
if (share.reverseShare) {
|
||||
await this.prisma.reverseShare.update({
|
||||
@@ -172,7 +175,7 @@ export class ShareService {
|
||||
});
|
||||
}
|
||||
|
||||
return await this.prisma.share.update({
|
||||
return this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
});
|
||||
@@ -192,17 +195,15 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true },
|
||||
include: { recipients: true, files: true },
|
||||
});
|
||||
|
||||
const sharesWithEmailRecipients = shares.map((share) => {
|
||||
return shares.map((share) => {
|
||||
return {
|
||||
...share,
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
};
|
||||
});
|
||||
|
||||
return sharesWithEmailRecipients;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<any> {
|
||||
@@ -222,7 +223,7 @@ export class ShareService {
|
||||
throw new NotFoundException("Share not found");
|
||||
return {
|
||||
...share,
|
||||
hasPassword: share.security?.password ? true : false,
|
||||
hasPassword: !!share.security?.password,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +279,7 @@ export class ShareService {
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password");
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
@@ -303,7 +304,7 @@ export class ShareService {
|
||||
},
|
||||
{
|
||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -315,7 +316,7 @@ export class ShareService {
|
||||
|
||||
try {
|
||||
const claims = this.jwtService.verify(token, {
|
||||
secret: this.config.get("JWT_SECRET"),
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
// Ignore expiration if expiration is 0
|
||||
ignoreExpiration: moment(expiration).isSame(0),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { Allow } from "class-validator";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Allow, IsOptional, MinLength } from "class-validator";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class CreateUserDTO extends UserDTO {
|
||||
@Expose()
|
||||
@Allow()
|
||||
isAdmin: boolean;
|
||||
|
||||
@MinLength(8)
|
||||
@IsOptional()
|
||||
password: string;
|
||||
|
||||
from(partial: Partial<CreateUserDTO>) {
|
||||
return plainToClass(CreateUserDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Response } from "express";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
@@ -40,7 +42,16 @@ export class UserController {
|
||||
|
||||
@Delete("me")
|
||||
@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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { UserController } from "./user.controller";
|
||||
import { UserSevice } from "./user.service";
|
||||
|
||||
@Module({
|
||||
imports: [EmailModule],
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
return await this.prisma.user.findMany();
|
||||
@@ -18,7 +23,17 @@ export class UserSevice {
|
||||
}
|
||||
|
||||
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);
|
||||
await this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||
} else {
|
||||
hash = await argon.hash(dto.password);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.user.create({
|
||||
data: {
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- "./data:/opt/app/backend/data"
|
||||
- "./data/images:/opt/app/frontend/public/img"
|
||||
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
|
||||
# depends_on:
|
||||
# clamav:
|
||||
|
||||
95
docs/CONTRIBUTING.es.md
Normal file
@@ -0,0 +1,95 @@
|
||||
_Leer esto en otro idioma: [Inglés](/CONTRIBUTING.md), [Español](/docs/CONTRIBUTING.es.md), [Chino Simplificado](/docs/CONTRIBUTING.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
# Contribuyendo
|
||||
|
||||
¡Nos ❤️ encantaría que contribuyas a Pingvin Share y nos ayudes a hacerlo mejor! Todas las contribuciones son bienvenidas, incluyendo problemas, sugerencias, _pull requests_ y más.
|
||||
|
||||
## Para comenzar
|
||||
|
||||
Si encontraste un error, tienes una sugerencia o algo más, simplemente crea un problema (issue) en GitHub y nos pondremos en contacto contigo 😊.
|
||||
|
||||
## Para hacer una Pull Request
|
||||
|
||||
Antes de enviar la pull request para su revisión, asegúrate de que:
|
||||
|
||||
- El nombre de la pull request sigue las [especificaciones de Commits Convencionales](https://www.conventionalcommits.org/):
|
||||
|
||||
`<tipo>[ámbito opcional]: <descripción>`
|
||||
|
||||
ejemplo:
|
||||
|
||||
```
|
||||
feat(share): agregar protección con contraseña
|
||||
```
|
||||
|
||||
Donde `tipo` puede ser:
|
||||
|
||||
- **feat** - es una nueva función
|
||||
- **doc** - cambios solo en la documentación
|
||||
- **fix** - una corrección de error
|
||||
- **refactor** - cambios en el código que no solucionan un error ni agregan una función
|
||||
|
||||
- Tu pull requests tiene una descripción detallada.
|
||||
|
||||
- Ejecutaste `npm run format` para formatear el código.
|
||||
|
||||
<details>
|
||||
<summary>¿No sabes como crear una pull request? Aprende cómo crear una pull request</summary>
|
||||
|
||||
1. Crea un fork del repositorio haciendo clic en el botón `Fork` en el repositorio de Pingvin Share.
|
||||
|
||||
2. Clona tu fork en tu máquina con `git clone`.
|
||||
|
||||
```
|
||||
$ git clone https://github.com/[your_username]/pingvin-share
|
||||
```
|
||||
|
||||
3. Trabajar - hacer commit - repetir
|
||||
|
||||
4. Haz un `push` de tus cambios a GitHub.
|
||||
|
||||
```
|
||||
$ git push origin [nombre_de_tu_nueva_rama]
|
||||
```
|
||||
|
||||
5. Envía tus cambios para su revisión. Si vas a tu repositorio en GitHub, verás un botón `Comparar y crear pull requests`. Haz clic en ese botón.
|
||||
6. Inicia una Pull Request
|
||||
7. Ahora envía la pull requests y haz clic en `Crear pull requests`
|
||||
8. Espera a que alguien revise tu solicitud y apruebe o rechace tus cambios. Puedes ver los comentarios en la página de la solicitud en GitHub.
|
||||
|
||||
</details>
|
||||
|
||||
## Instalación del proyecto
|
||||
|
||||
Pingvin Share consiste de un frontend y un backend.
|
||||
|
||||
### Backend
|
||||
|
||||
El backend está hecho con [Nest.js](https://nestjs.com) y usa Typescript.
|
||||
|
||||
#### Instalación
|
||||
|
||||
1. Abrimos la carpeta `backend`
|
||||
2. Instalamos las dependencias con `npm install`
|
||||
3. Haz un `push` del esquema de la base de datos a la base de datos ejecutando `npx prisma db push`
|
||||
4. Rellena la base de datos ejecutando `npx prisma db seed`
|
||||
5. Inicia el backend con `npm run dev`
|
||||
|
||||
### Frontend
|
||||
|
||||
El frontend está hecho con [Next.js](https://nextjs.org) y usa Typescript.
|
||||
|
||||
#### Instalación
|
||||
|
||||
1. Primero inicia el backend
|
||||
2. Abre la carpeta `frontend`
|
||||
3. Instala las dependencias con `npm install`
|
||||
4. Inicia el frontend con `npm run dev`
|
||||
|
||||
¡Ya está todo listo!
|
||||
|
||||
### Testing
|
||||
|
||||
Por el momento, solo tenemos pruebas para el backend. Para ejecutar estas pruebas, debes ejecutar el comando `npm run test:system` en la carpeta del backend.
|
||||
97
docs/CONTRIBUTING.zh-cn.md
Normal file
@@ -0,0 +1,97 @@
|
||||
_选择合适的语言阅读: [西班牙语](/docs/CONTRIBUTING.es.md), [英语](/CONTRIBUTING.md), [简体中文](/docs/CONTRIBUTING.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
# 提交贡献
|
||||
|
||||
我们非常感谢你 ❤️ 为 Pingvin Share 提交贡献使其变得更棒! 欢迎任何形式的贡献,包括 issues, 建议, PRs 和其他形式
|
||||
|
||||
## 小小的开始
|
||||
|
||||
你找到了一个 bug,有新特性建议或者其他提议,请在 GitHub 建立一个 issue 以便我和你联络 😊
|
||||
|
||||
## 提交一个 Pull Request
|
||||
|
||||
在你提交 PR 前请确保
|
||||
|
||||
- PR 的名字遵守 [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||
|
||||
`<type>[optional scope]: <description>`
|
||||
|
||||
例如:
|
||||
|
||||
```
|
||||
feat(share): add password protection
|
||||
```
|
||||
|
||||
`TYPE` 可以是:
|
||||
|
||||
- **feat** - 这是一个新特性 feature
|
||||
- **doc** - 仅仅改变了文档部分 documentation
|
||||
- **fix** - 修复了一个 bug
|
||||
- **refactor** - 更新了代码,但是并非出于增加新特性 feature 或修复 bug 的目的
|
||||
|
||||
- 请在 PR 中附详细的解释说明
|
||||
- 使用 `npm run format` 格式化你的代码
|
||||
|
||||
<details>
|
||||
<summary>不知道怎么发起一个 PR? 点开了解怎么发起一个 PR </summary>
|
||||
|
||||
1. 点击 Pingvin Share 仓库的 `Fork` 按钮,复制一份你的仓库
|
||||
|
||||
2. 通过 `git clone` 将你的仓库克隆到本地
|
||||
|
||||
```
|
||||
$ git clone https://github.com/[你的用户名]/pingvin-share
|
||||
```
|
||||
|
||||
3. 进行你的修改 - 提交 commit 你的修改 - 重复直到完成
|
||||
|
||||
4. 将你的修改提交到 GitHub
|
||||
|
||||
```
|
||||
$ git push origin [你的新分支的名字]
|
||||
```
|
||||
|
||||
5. 提交你的代码以便代码审查
|
||||
|
||||
如果你进入你 fork 的 Github 仓库,你会看到一个 `Compare & pull request` 按钮,点击该按钮
|
||||
|
||||
6. 发起一个 PR
|
||||
7. 点击 `Create pull request` 来提交你的 PR
|
||||
8. 等待代码审查,通过或以某些原因拒绝
|
||||
|
||||
</details>
|
||||
|
||||
## 配置开发项目
|
||||
|
||||
Pingvin Share 包括前端和后端部分
|
||||
|
||||
### 后端
|
||||
|
||||
后端使用 [Nest.js](https://nestjs.com) 建立,使用 Typescript
|
||||
|
||||
#### 搭建
|
||||
|
||||
1. 打开 `backend` 文件夹
|
||||
2. 使用 `npm install` 安装依赖
|
||||
3. 通过 `npx prisma db push` 配置数据库结构
|
||||
4. 通过 `npx prisma db seed` 初始化数据库数据
|
||||
5. 通过 `npm run dev` 启动后端
|
||||
|
||||
### 前端
|
||||
|
||||
后端使用 [Next.js](https://nextjs.org) 建立,使用 Typescript
|
||||
|
||||
#### 搭建
|
||||
|
||||
1. 首先启动后端
|
||||
2. 打开 `frontend` 文件夹
|
||||
3. 通过 `npm install` 安装依赖
|
||||
4. 通过 `npm run dev` 启动前端
|
||||
|
||||
开发项目配置完成
|
||||
|
||||
### 测试
|
||||
|
||||
目前阶段我们只有后端的系统测试,在 `backend` 文件夹运行 `npm run test:system` 来执行系统测试
|
||||
128
docs/README.es.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alternativa a WeTransfer.
|
||||
|
||||
## ✨ Características
|
||||
|
||||
- Compartir archivos utilizando un enlace
|
||||
- Tamaño de archivo ilimitado (unicamente restringido por el espacio en disco)
|
||||
- Establecer una fecha de caducidad para los recursos compartidos
|
||||
- Uso compartido seguro con límites de visitantes y contraseñas
|
||||
- Destinatarios de correo electrónico
|
||||
- Integración con ClamAV para escaneos de seguridad
|
||||
|
||||
## 🐧 Conoce Pingvin Share
|
||||
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Reseña por DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ Instalación
|
||||
|
||||
> Nota: Pingvin Share está en sus primeras etapas y puede contener errores.
|
||||
|
||||
### Instalación con Docker (recomendada)
|
||||
|
||||
1. Descarge el archivo `docker-compose.yml`
|
||||
2. Ejecute `docker-compose up -d`
|
||||
|
||||
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||
|
||||
### Instalación autónoma
|
||||
|
||||
Herramientas requeridas:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) para ejecutar Pingvin Share en segundo plano
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# Consultar la última versión
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Iniciar el backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
# Iniciar el frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||
|
||||
### Integraciones
|
||||
|
||||
#### ClamAV (Unicamente con Docker)
|
||||
|
||||
ClamAV se utiliza para escanear los recursos compartidos en busca de archivos maliciosos y eliminarlos si los encuentra.
|
||||
|
||||
1. Añade el contenedor ClamAV al stack de Docker Compose (ver `docker-compose.yml`) e inicie el contenedor.
|
||||
2. Docker esperará a que ClamAV se inicie antes de iniciar Pingvin Share. Esto puede tardar uno o dos minutos.
|
||||
3. Los registros de Pingvin Share ahora deberían decir "ClamAV está activo".
|
||||
|
||||
Por favor, ten en cuenta que ClamAV necesita muchos [recursos](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
### Recursos adicionales
|
||||
|
||||
- [Instalación en Synology NAS (Inglés)](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### Actualizar a una nueva versión
|
||||
|
||||
Dado que Pingvin Share se encuentra en una fase inicial, consulte las notas de la versión para conocer los cambios de última hora antes de actualizar.
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Instalación autónoma
|
||||
|
||||
1. Deten la aplicación en ejecución
|
||||
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
|
||||
2. Repite los pasos de la [guía de instalación](#instalación-autonoma) excepto el paso de `git clone`.
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# Consultar la última versión
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Iniciar el backend
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Iniciar frontend
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### Marca personalizada
|
||||
|
||||
Puedes cambiar el nombre y el logotipo de la aplicación visitando la página de configuración de administrador.
|
||||
|
||||
## 🖤 Contribuye
|
||||
|
||||
¡Eres bienvenido a contribuir a Pingvin Share! Sige la [guía de contribución](/CONTRIBUTING.md) para empezar.
|
||||
126
docs/README.zh-cn.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share 是一个可自建的文件分享平台,是 WeTransfer 的一个替代品
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 通过可自定义后缀的链接分享文件
|
||||
- 可自定义任意大小的文件上传限制 (受制于托管所在的硬盘大小)
|
||||
- 对共享链接设置有效期限
|
||||
- 对共享链接设置访问次数和访问密码
|
||||
- 通过邮件自动发送共享链接
|
||||
- 整合 ClamAV 进行反病毒检查
|
||||
|
||||
## 🐧 了解一下 Pingvin Share
|
||||
|
||||
- [示例网站](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [DB Tech 推荐视频](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ 自建指南
|
||||
|
||||
> 注意:Pingvin Share 仍处于开发阶段并且可能存在 bugs
|
||||
|
||||
### Docker 部署 (推荐)
|
||||
|
||||
1. 下载 `docker-compose.yml`
|
||||
2. 运行命令 `docker-compose up -d`
|
||||
|
||||
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||
|
||||
### Stand-alone 部署
|
||||
|
||||
必须的依赖:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) 用于后台运行 Pingvin Share
|
||||
|
||||
```bash
|
||||
git clone https://github.com/stonith404/pingvin-share
|
||||
cd pingvin-share
|
||||
|
||||
# 获取最新的版本
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# 启动后端 backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
# 启动前端 frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||
|
||||
### 整合组件
|
||||
|
||||
#### ClamAV (仅限 Docker 部署)
|
||||
|
||||
扫描上传文件中是否存在可疑文件,如果存在 ClamAV 会自动移除
|
||||
|
||||
1. 在 docker-compose 配置中添加 ClamAV 容器 (见 `docker-compose.yml` 注释部分) 并启动容器
|
||||
2. Docker 会在启动 Pingvin Share 前启动 ClamAV,也许会花费 1-2 分钟
|
||||
3. Pingvin Share 日志中应该有 "ClamAV is active"
|
||||
|
||||
请注意 ClamAV 会消耗很多 [系统资源(特别是内存)](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)
|
||||
|
||||
### 更多资源
|
||||
|
||||
- [群晖 NAS 配置](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### 升级
|
||||
|
||||
因为 Pingvin Share 仍处在开发阶段,在升级前请务必阅读 release notes 避免不可逆的改变
|
||||
|
||||
#### Docker 升级
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Stand-alone 升级
|
||||
|
||||
1. 停止正在运行的 app
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. 重复 [installation guide](#stand-alone-installation) 中的步骤,除了 `git clone` 这一步
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# 获取最新的版本
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# 启动后端 backend
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# 启动前端 frontend
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### 自定义品牌
|
||||
|
||||
你可以在管理员配置页面改变网站的名字和 logo
|
||||
|
||||
## 🖤 提交贡献
|
||||
|
||||
非常欢迎向 Pingvin Share 提交贡献! 请阅读 [contribution guide](/CONTRIBUTING.md) 来提交你的贡献
|
||||
@@ -4,11 +4,22 @@ const { version } = require('./package.json');
|
||||
|
||||
const withPWA = require("next-pwa")({
|
||||
dest: "public",
|
||||
disable: process.env.NODE_ENV == "development",
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
reloadOnOnline: false,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https?.*/,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
reloadOnOnline: false,
|
||||
});
|
||||
|
||||
module.exports = withPWA({
|
||||
output: "standalone", env: {
|
||||
VERSION: version,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
apiURL: process.env.API_URL ?? 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
|
||||
1890
frontend/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.16.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -9,43 +9,44 @@
|
||||
"format": "prettier --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.10.0",
|
||||
"@mantine/dropzone": "^5.10.0",
|
||||
"@mantine/form": "^5.10.0",
|
||||
"@mantine/hooks": "^5.10.0",
|
||||
"@mantine/modals": "^5.10.0",
|
||||
"@mantine/next": "^5.10.0",
|
||||
"@mantine/notifications": "^5.10.0",
|
||||
"axios": "^1.2.2",
|
||||
"@mantine/core": "^6.0.1",
|
||||
"@mantine/dropzone": "^6.0.1",
|
||||
"@mantine/form": "^6.0.1",
|
||||
"@mantine/hooks": "^6.0.1",
|
||||
"@mantine/modals": "^6.0.1",
|
||||
"@mantine/next": "^6.0.1",
|
||||
"@mantine/notifications": "^6.0.1",
|
||||
"axios": "^1.3.4",
|
||||
"cookies-next": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.11.2",
|
||||
"jose": "^4.13.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.1.2",
|
||||
"next": "^13.2.4",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
"p-limit": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.7.1",
|
||||
"yup": "^0.32.11"
|
||||
"react-icons": "^4.8.0",
|
||||
"sharp": "^0.31.3",
|
||||
"yup": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "18.11.18",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"axios": "^1.2.2",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "^13.1.2",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"prettier": "^2.8.2",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"axios": "^1.3.4",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^13.2.4",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"prettier": "^2.8.4",
|
||||
"tar": "^6.1.13",
|
||||
"typescript": "^4.9.4"
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/img/logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -1 +0,0 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>
|
||||
|
Before Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 12 KiB |
@@ -8,55 +8,55 @@
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"src": "img/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"src": "img/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"src": "img/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"src": "img/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"src": "img/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"src": "img/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"src": "img/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"src": "img/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"src": "img/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
|
||||
@@ -1,34 +1,6 @@
|
||||
import Image from "next/image";
|
||||
|
||||
const Logo = ({ height, width }: { height: number; width: number }) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <Image src="/img/logo.png" alt="logo" height={height} width={width} />;
|
||||
};
|
||||
export default Logo;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Head from "next/head";
|
||||
import useConfig from "../hooks/config.hook";
|
||||
|
||||
const Meta = ({
|
||||
title,
|
||||
@@ -7,7 +8,9 @@ const Meta = ({
|
||||
title: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const metaTitle = `${title} - Pingvin Share`;
|
||||
const config = useConfig();
|
||||
|
||||
const metaTitle = `${title} - ${config.get("general.appName")}`;
|
||||
|
||||
return (
|
||||
<Head>
|
||||
@@ -19,7 +22,6 @@ const Meta = ({
|
||||
description ?? "An open-source and self-hosted sharing platform."
|
||||
}
|
||||
/>
|
||||
<meta property="og:image" content="/img/opengraph-default.png" />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
</Head>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
@@ -27,7 +26,7 @@ const showEnableTotpModal = (
|
||||
}
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Enable TOTP</Title>,
|
||||
title: "Enable TOTP",
|
||||
children: (
|
||||
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
|
||||
),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Text, Divider, Progress, Stack, Group, Flex } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { MyShare } from "../../types/share.type";
|
||||
import moment from "moment";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import CopyTextField from "../upload/CopyTextField";
|
||||
import { FileMetaData } from "../../types/File.type";
|
||||
|
||||
const showShareInformationsModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: MyShare,
|
||||
appUrl: string,
|
||||
maxShareSize: number
|
||||
) => {
|
||||
const link = `${appUrl}/share/${share.id}`;
|
||||
|
||||
let shareSize: number = 0;
|
||||
for (let file of share.files as FileMetaData[])
|
||||
shareSize += parseInt(file.size);
|
||||
|
||||
const formattedShareSize = byteToHumanSizeString(shareSize);
|
||||
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
|
||||
const shareSizeProgress = (shareSize / maxShareSize) * 100;
|
||||
|
||||
const formattedCreatedAt = moment(share.createdAt).format("LLL");
|
||||
const formattedExpiration =
|
||||
moment(share.expiration).unix() === 0
|
||||
? "Never"
|
||||
: moment(share.expiration).format("LLL");
|
||||
|
||||
return modals.openModal({
|
||||
title: "Share informations",
|
||||
|
||||
children: (
|
||||
<Stack align="stretch" spacing="md">
|
||||
<Text size="sm" color="lightgray">
|
||||
<b>ID:</b> {share.id}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<b>Description:</b> {share.description || "No description"}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<b>Created at:</b> {formattedCreatedAt}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<b>Expires at:</b> {formattedExpiration}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<CopyTextField link={link} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<b>Size:</b> {formattedShareSize} / {formattedMaxShareSize} (
|
||||
{shareSizeProgress.toFixed(1)}%)
|
||||
</Text>
|
||||
|
||||
<Flex align="center" justify="center">
|
||||
{shareSize / maxShareSize < 0.1 && (
|
||||
<Text size="xs" color="lightgray" style={{ marginRight: "4px" }}>
|
||||
{formattedShareSize}
|
||||
</Text>
|
||||
)}
|
||||
<Progress
|
||||
value={shareSizeProgress}
|
||||
label={shareSize / maxShareSize >= 0.1 ? formattedShareSize : ""}
|
||||
style={{ width: shareSize / maxShareSize < 0.1 ? "70%" : "80%" }}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
/>
|
||||
<Text size="xs" color="lightgray" style={{ marginLeft: "4px" }}>
|
||||
{formattedMaxShareSize}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export default showShareInformationsModal;
|
||||
@@ -18,10 +18,13 @@ const AdminConfigInput = ({
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
stringValue: configVariable.value,
|
||||
textValue: configVariable.value,
|
||||
numberValue: parseInt(configVariable.value),
|
||||
booleanValue: configVariable.value == "true",
|
||||
stringValue: configVariable.value ?? configVariable.defaultValue,
|
||||
textValue: configVariable.value ?? configVariable.defaultValue,
|
||||
numberValue: parseInt(
|
||||
configVariable.value ?? configVariable.defaultValue
|
||||
),
|
||||
booleanValue:
|
||||
(configVariable.value ?? configVariable.defaultValue) == "true",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,29 +38,38 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "string" &&
|
||||
(configVariable.obscured ? (
|
||||
<PasswordInput
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
{...form.getInputProps("stringValue")}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
{...form.getInputProps("stringValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{configVariable.type == "text" && (
|
||||
<Textarea
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
autosize
|
||||
{...form.getInputProps("textValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(number) => onValueChange(configVariable, number)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useRouter } from "next/router";
|
||||
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 router = useRouter();
|
||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||
|
||||
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
|
||||
UpdateConfig[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.get("SETUP_STATUS") != "FINISHED") {
|
||||
config.refresh();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||
const index = updatedConfigVariables.findIndex(
|
||||
(item) => item.key === configVariable.key
|
||||
);
|
||||
if (index > -1) {
|
||||
updatedConfigVariables[index] = configVariable;
|
||||
} else {
|
||||
setUpdatedConfigVariables([...updatedConfigVariables, 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);
|
||||
});
|
||||
};
|
||||
|
||||
const saveConfigVariables = async () => {
|
||||
if (config.get("SETUP_STATUS") == "REGISTERED") {
|
||||
await configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(async () => {
|
||||
await configService.finishSetup();
|
||||
router.replace("/upload");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
} else {
|
||||
await configService
|
||||
.updateMany(updatedConfigVariables)
|
||||
.then(() => {
|
||||
setUpdatedConfigVariables([]);
|
||||
toast.success("Configurations updated successfully");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
}
|
||||
config.refresh();
|
||||
};
|
||||
|
||||
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
|
||||
configVariablesChanged={updatedConfigVariables.length != 0}
|
||||
saveConfigVariables={saveConfigVariables}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<Group position="right">
|
||||
<Button onClick={saveConfigVariables}>Save</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfigTable;
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Header,
|
||||
MediaQuery,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import useConfig from "../../../hooks/config.hook";
|
||||
import Logo from "../../Logo";
|
||||
|
||||
const ConfigurationHeader = ({
|
||||
isMobileNavBarOpened,
|
||||
setIsMobileNavBarOpened,
|
||||
}: {
|
||||
isMobileNavBarOpened: boolean;
|
||||
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<Header height={60} p="md">
|
||||
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||||
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||
<Burger
|
||||
opened={isMobileNavBarOpened}
|
||||
onClick={() => setIsMobileNavBarOpened((o) => !o)}
|
||||
size="sm"
|
||||
color={theme.colors.gray[6]}
|
||||
mr="xl"
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Group position="apart" w="100%">
|
||||
<Link href="/" passHref>
|
||||
<Group>
|
||||
<Logo height={35} width={35} />
|
||||
<Text weight={600}>{config.get("general.appName")}</Text>
|
||||
</Group>
|
||||
</Link>
|
||||
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
|
||||
<Button variant="light" component={Link} href="/admin">
|
||||
Go back
|
||||
</Button>
|
||||
</MediaQuery>
|
||||
</Group>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationHeader;
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
createStyles,
|
||||
Group,
|
||||
MediaQuery,
|
||||
Navbar,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||
|
||||
const categories = [
|
||||
{ name: "General", icon: <TbSquare /> },
|
||||
{ name: "Email", icon: <TbMail /> },
|
||||
{ name: "Share", icon: <TbShare /> },
|
||||
{ name: "SMTP", icon: <TbAt /> },
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
activeLink: {
|
||||
backgroundColor: theme.fn.variant({
|
||||
variant: "light",
|
||||
color: theme.primaryColor,
|
||||
}).background,
|
||||
color: theme.fn.variant({ variant: "light", color: theme.primaryColor })
|
||||
.color,
|
||||
|
||||
borderRadius: theme.radius.sm,
|
||||
fontWeight: 600,
|
||||
},
|
||||
}));
|
||||
|
||||
const ConfigurationNavBar = ({
|
||||
categoryId,
|
||||
isMobileNavBarOpened,
|
||||
setIsMobileNavBarOpened,
|
||||
}: {
|
||||
categoryId: string;
|
||||
isMobileNavBarOpened: boolean;
|
||||
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<Navbar
|
||||
p="md"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden={!isMobileNavBarOpened}
|
||||
width={{ sm: 200, lg: 300 }}
|
||||
>
|
||||
<Navbar.Section>
|
||||
<Text size="xs" color="dimmed" mb="sm">
|
||||
Configuration
|
||||
</Text>
|
||||
<Stack spacing="xs">
|
||||
{categories.map((category) => (
|
||||
<Box
|
||||
p="xs"
|
||||
component={Link}
|
||||
onClick={() => setIsMobileNavBarOpened(false)}
|
||||
className={
|
||||
categoryId == category.name.toLowerCase()
|
||||
? classes.activeLink
|
||||
: undefined
|
||||
}
|
||||
key={category.name}
|
||||
href={`/admin/config/${category.name.toLowerCase()}`}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon
|
||||
variant={
|
||||
categoryId == category.name.toLowerCase()
|
||||
? "filled"
|
||||
: "light"
|
||||
}
|
||||
>
|
||||
{category.icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{category.name}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Navbar.Section>
|
||||
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||
<Button mt="xl" variant="light" component={Link} href="/admin">
|
||||
Go back
|
||||
</Button>
|
||||
</MediaQuery>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationNavBar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Box, FileInput, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbUpload } from "react-icons/tb";
|
||||
|
||||
const LogoConfigInput = ({
|
||||
logo,
|
||||
setLogo,
|
||||
}: {
|
||||
logo: File | null;
|
||||
setLogo: Dispatch<SetStateAction<File | null>>;
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||
|
||||
return (
|
||||
<Group position="apart">
|
||||
<Stack style={{ maxWidth: isMobile ? "100%" : "40%" }} spacing={0}>
|
||||
<Title order={6}>Logo</Title>
|
||||
<Text color="dimmed" size="sm" mb="xs">
|
||||
Change your logo by uploading a new image. The image must be a PNG and
|
||||
should have the format 1:1.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack></Stack>
|
||||
<Box style={{ width: isMobile ? "100%" : "50%" }}>
|
||||
<FileInput
|
||||
clearable
|
||||
icon={<TbUpload size={14} />}
|
||||
value={logo}
|
||||
onChange={(v) => setLogo(v)}
|
||||
accept=".png"
|
||||
placeholder="Pick image"
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoConfigInput;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||
import User from "../../types/user.type";
|
||||
import User from "../../../types/user.type";
|
||||
import showUpdateUserModal from "./showUpdateUserModal";
|
||||
|
||||
const ManageUserTable = ({
|
||||
@@ -5,43 +5,48 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
import userService from "../../services/user.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
import userService from "../../../services/user.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const showCreateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
smtpEnabled: boolean,
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Create user</Title>,
|
||||
children: <Body modals={modals} getUsers={getUsers} />,
|
||||
title: "Create user",
|
||||
children: (
|
||||
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({
|
||||
modals,
|
||||
smtpEnabled,
|
||||
getUsers,
|
||||
}: {
|
||||
modals: ModalsContextProps;
|
||||
smtpEnabled: boolean;
|
||||
getUsers: () => void;
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
password: undefined,
|
||||
isAdmin: false,
|
||||
setPasswordManually: false,
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
password: yup.string().min(8),
|
||||
password: yup.string().min(8).optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -62,14 +67,34 @@ const Body = ({
|
||||
<Stack>
|
||||
<TextInput label="Username" {...form.getInputProps("username")} />
|
||||
<TextInput label="Email" {...form.getInputProps("email")} />
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{smtpEnabled && (
|
||||
<Switch
|
||||
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
|
||||
styles={{
|
||||
body: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
}}
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Admin privileges"
|
||||
description="If checked, the user will be able to access the admin panel."
|
||||
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||
/>
|
||||
<Group position="right">
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
import userService from "../../services/user.service";
|
||||
import User from "../../types/user.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
import userService from "../../../services/user.service";
|
||||
import User from "../../../types/user.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const showUpdateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
@@ -21,7 +20,7 @@ const showUpdateUserModal = (
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Update {user.username}</Title>,
|
||||
title: `Update ${user.username}`,
|
||||
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
||||
});
|
||||
};
|
||||
@@ -90,7 +89,7 @@ const Body = ({
|
||||
</form>
|
||||
<Accordion>
|
||||
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
||||
<Accordion.Control>Change password</Accordion.Control>
|
||||
<Accordion.Control px={0}>Change password</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||
@@ -32,11 +32,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
totp: yup.string().when("totpRequired", {
|
||||
is: true,
|
||||
then: yup.string().min(6).max(6).required(),
|
||||
otherwise: yup.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
@@ -79,8 +74,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
router.replace(redirectPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.response?.data?.message == "Login token expired") {
|
||||
toast.error("Login token expired");
|
||||
if (error?.response?.data?.error == "share_password_required") {
|
||||
toast.axiosError(error);
|
||||
// Refresh the page to start over
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -95,7 +90,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Welcome back
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
{config.get("share.allowRegistration") && (
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
You don't have an account yet?{" "}
|
||||
<Anchor component={Link} href={"signUp"} size="sm">
|
||||
@@ -131,7 +126,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
{...form.getInputProps("totp")}
|
||||
/>
|
||||
)}
|
||||
{config.get("SMTP_ENABLED") && (
|
||||
{config.get("smtp.enabled") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
Forgot password?
|
||||
|
||||
@@ -41,8 +41,12 @@ const SignUpForm = () => {
|
||||
await authService
|
||||
.signUp(email, username, password)
|
||||
.then(async () => {
|
||||
await refreshUser();
|
||||
router.replace("/upload");
|
||||
const user = await refreshUser();
|
||||
if (user?.isAdmin) {
|
||||
router.replace("/admin/intro");
|
||||
} else {
|
||||
router.replace("/upload");
|
||||
}
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
@@ -52,7 +56,7 @@ const SignUpForm = () => {
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Sign up
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
{config.get("share.allowRegistration") && (
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
You have an account already?{" "}
|
||||
<Anchor component={Link} href={"signIn"} size="sm">
|
||||
|
||||
41
frontend/src/components/core/SortIcon.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbChevronDown, TbChevronUp, TbSelector } from "react-icons/tb";
|
||||
|
||||
export type TableSort = {
|
||||
property?: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
const TableSortIcon = ({
|
||||
sort,
|
||||
setSort,
|
||||
property,
|
||||
}: {
|
||||
sort: TableSort;
|
||||
setSort: Dispatch<SetStateAction<TableSort>>;
|
||||
property: string;
|
||||
}) => {
|
||||
if (sort.property === property) {
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
setSort({
|
||||
property,
|
||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
>
|
||||
{sort.direction === "asc" ? <TbChevronDown /> : <TbChevronUp />}
|
||||
</ActionIcon>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ActionIcon onClick={() => setSort({ property, direction: "asc" })}>
|
||||
<TbSelector />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TableSortIcon;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Header,
|
||||
Header as MantineHeader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -108,7 +108,7 @@ const useStyles = createStyles((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const NavBar = () => {
|
||||
const Header = () => {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
@@ -141,20 +141,20 @@ const NavBar = () => {
|
||||
},
|
||||
];
|
||||
|
||||
if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
if (config.get("share.allowUnauthenticatedShares")) {
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
});
|
||||
}
|
||||
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
if (config.get("general.showHomePage"))
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/",
|
||||
label: "Home",
|
||||
});
|
||||
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
if (config.get("share.allowRegistration"))
|
||||
unauthenticatedLinks.push({
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
@@ -187,12 +187,12 @@ const NavBar = () => {
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||
<MantineHeader height={HEADER_HEIGHT} mb={40} className={classes.root}>
|
||||
<Container className={classes.header}>
|
||||
<Link href="/" passHref>
|
||||
<Group>
|
||||
<Logo height={35} width={35} />
|
||||
<Text weight={600}>Pingvin Share</Text>
|
||||
<Text weight={600}>{config.get("general.appName")}</Text>
|
||||
</Group>
|
||||
</Link>
|
||||
<Group spacing={5} className={classes.links}>
|
||||
@@ -212,8 +212,8 @@ const NavBar = () => {
|
||||
)}
|
||||
</Transition>
|
||||
</Container>
|
||||
</Header>
|
||||
</MantineHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
export default Header;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -8,9 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import mime from "mime-types";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
@@ -18,13 +17,17 @@ import { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
import TableSortIcon, { TableSort } from "../core/SortIcon";
|
||||
import showFilePreviewModal from "./modals/showFilePreviewModal";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
setShare,
|
||||
share,
|
||||
isLoading,
|
||||
}: {
|
||||
files?: FileMetaData[];
|
||||
setShare: Dispatch<SetStateAction<Share | undefined>>;
|
||||
share: Share;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
@@ -32,10 +35,36 @@ const FileList = ({
|
||||
const config = useConfig();
|
||||
const modals = useModals();
|
||||
|
||||
const [sort, setSort] = useState<TableSort>({
|
||||
property: undefined,
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
const sortFiles = () => {
|
||||
if (files && sort.property) {
|
||||
const sortedFiles = files.sort((a: any, b: any) => {
|
||||
if (sort.direction === "asc") {
|
||||
return b[sort.property!].localeCompare(a[sort.property!], undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
} else {
|
||||
return a[sort.property!].localeCompare(b[sort.property!], undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setShare({
|
||||
...share,
|
||||
files: sortedFiles,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyFileLink = (file: FileMetaData) => {
|
||||
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${
|
||||
file.id
|
||||
}`;
|
||||
const link = `${config.get("general.appUrl")}/api/shares/${
|
||||
share.id
|
||||
}/files/${file.id}`;
|
||||
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(link);
|
||||
@@ -52,55 +81,70 @@ const FileList = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(sortFiles, [sort]);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
Name
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="name" />
|
||||
</Group>
|
||||
</th>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
Size
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="size" />
|
||||
</Group>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
showFilePreviewModal(share.id, file, modals)
|
||||
}
|
||||
size={25}
|
||||
>
|
||||
<TbEye />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={() => copyFileLink(file)}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/share/${share.id}/preview/${
|
||||
file.id
|
||||
}?type=${mime.contentType(file.name)}`}
|
||||
target="_blank"
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbEye />
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
157
frontend/src/components/share/FilePreview.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Button, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import Link from "next/link";
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import api from "../../services/api.service";
|
||||
|
||||
const FilePreviewContext = React.createContext<{
|
||||
shareId: string;
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
setIsNotSupported: Dispatch<SetStateAction<boolean>>;
|
||||
}>({
|
||||
shareId: "",
|
||||
fileId: "",
|
||||
mimeType: "",
|
||||
setIsNotSupported: () => {},
|
||||
});
|
||||
|
||||
const FilePreview = ({
|
||||
shareId,
|
||||
fileId,
|
||||
mimeType,
|
||||
}: {
|
||||
shareId: string;
|
||||
fileId: string;
|
||||
mimeType: string;
|
||||
}) => {
|
||||
const [isNotSupported, setIsNotSupported] = useState(false);
|
||||
if (isNotSupported) return <UnSupportedFile />;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<FilePreviewContext.Provider
|
||||
value={{ shareId, fileId, mimeType, setIsNotSupported }}
|
||||
>
|
||||
<FileDecider />
|
||||
</FilePreviewContext.Provider>
|
||||
<Button
|
||||
variant="subtle"
|
||||
component={Link}
|
||||
onClick={() => modals.closeAll()}
|
||||
target="_blank"
|
||||
href={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
>
|
||||
View original file
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const FileDecider = () => {
|
||||
const { mimeType, setIsNotSupported } = React.useContext(FilePreviewContext);
|
||||
|
||||
if (mimeType == "application/pdf") {
|
||||
return <PdfPreview />;
|
||||
} else if (mimeType.startsWith("video/")) {
|
||||
return <VideoPreview />;
|
||||
} else if (mimeType.startsWith("image/")) {
|
||||
return <ImagePreview />;
|
||||
} else if (mimeType.startsWith("audio/")) {
|
||||
return <AudioPreview />;
|
||||
} else if (mimeType.startsWith("text/")) {
|
||||
return <TextPreview />;
|
||||
} else {
|
||||
setIsNotSupported(true);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const AudioPreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10} style={{ width: "100%" }}>
|
||||
<audio controls style={{ width: "100%" }}>
|
||||
<source
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
</audio>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoPreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
<video width="100%" controls>
|
||||
<source
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
</video>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePreview = () => {
|
||||
const { shareId, fileId, setIsNotSupported } =
|
||||
React.useContext(FilePreviewContext);
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
alt={`${fileId}_preview`}
|
||||
width="100%"
|
||||
onError={() => setIsNotSupported(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TextPreview = () => {
|
||||
const { shareId, fileId } = React.useContext(FilePreviewContext);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get(`/shares/${shareId}/files/${fileId}?download=false`)
|
||||
.then((res) => setText(res.data));
|
||||
}, [shareId, fileId]);
|
||||
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10} style={{ width: "100%" }}>
|
||||
<Text sx={{ whiteSpace: "pre-wrap" }} size="sm">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
const PdfPreview = () => {
|
||||
const { shareId, fileId } = React.useContext(FilePreviewContext);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const UnSupportedFile = () => {
|
||||
return (
|
||||
<Center style={{ minHeight: 200 }}>
|
||||
<Stack align="center" spacing={10}>
|
||||
<Title order={3}>Preview not supported</Title>
|
||||
<Text>
|
||||
A preview for thise file type is unsupported. Please download the file
|
||||
to view it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -34,8 +34,10 @@ const FileSizeInput = ({
|
||||
label={label}
|
||||
value={size}
|
||||
onChange={(value) => {
|
||||
setSize(value!);
|
||||
onChange(unitAndSizeToByte(unit, value!));
|
||||
if (value) {
|
||||
setSize(value);
|
||||
onChange(unitAndSizeToByte(unit, value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import CopyTextField from "../../upload/CopyTextField";
|
||||
|
||||
const showCompletedReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
@@ -14,11 +12,7 @@ const showCompletedReverseShareModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Reverse share link</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: "Reverse share link",
|
||||
children: <Body link={link} getReverseShares={getReverseShares} />,
|
||||
});
|
||||
};
|
||||
@@ -30,28 +24,11 @@ const Body = ({
|
||||
link: string;
|
||||
getReverseShares: () => void;
|
||||
}) => {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const modals = useModals();
|
||||
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<TextInput
|
||||
readOnly
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
window.isSecureContext && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<CopyTextField link={link} />
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
@@ -25,7 +24,7 @@ const showCreateReverseShareModal = (
|
||||
getReverseShares: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Create reverse share</Title>,
|
||||
title: "Create reverse share",
|
||||
children: (
|
||||
<Body
|
||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import mime from "mime-types";
|
||||
import { FileMetaData } from "../../../types/File.type";
|
||||
import FilePreview from "../FilePreview";
|
||||
|
||||
const showFilePreviewModal = (
|
||||
shareId: string,
|
||||
file: FileMetaData,
|
||||
modals: ModalsContextProps
|
||||
) => {
|
||||
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
|
||||
return modals.openModal({
|
||||
size: "xl",
|
||||
title: file.name,
|
||||
children: (
|
||||
<FilePreview shareId={shareId} fileId={file.id} mimeType={mimeType} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export default showFilePreviewModal;
|
||||
@@ -1,57 +1,52 @@
|
||||
import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, PasswordInput, Stack, Text } from "@mantine/core";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useState } from "react";
|
||||
|
||||
const showEnterPasswordModal = (
|
||||
modals: ModalsContextProps,
|
||||
submitCallback: any
|
||||
submitCallback: (password: string) => Promise<void>
|
||||
) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<>
|
||||
<Title order={4}>Password required</Title>
|
||||
<Text size="sm">
|
||||
This access this share please enter the password for the share.
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
title: "Password required",
|
||||
children: <Body submitCallback={submitCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ submitCallback }: { submitCallback: any }) => {
|
||||
const Body = ({
|
||||
submitCallback,
|
||||
}: {
|
||||
submitCallback: (password: string) => Promise<void>;
|
||||
}) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordWrong, setPasswordWrong] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="Password"
|
||||
error={passwordWrong && "Wrong password"}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
submitCallback(password)
|
||||
.then((res: any) => res)
|
||||
.catch((e: any) => {
|
||||
const error = e.response.data.message;
|
||||
if (error == "Wrong password") {
|
||||
setPasswordWrong(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
<Stack align="stretch">
|
||||
<Text size="sm">
|
||||
This access this share please enter the password for the share.
|
||||
</Text>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCallback(password);
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="Password"
|
||||
error={passwordWrong && "Wrong password"}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -12,7 +12,7 @@ const showErrorModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>{title}</Title>,
|
||||
title: title,
|
||||
|
||||
children: <Body text={text} />,
|
||||
});
|
||||
|
||||
48
frontend/src/components/upload/CopyTextField.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { ActionIcon, TextInput } from "@mantine/core";
|
||||
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
|
||||
function CopyTextField(props: { link: string }) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const [checkState, setCheckState] = useState(false);
|
||||
const [textClicked, setTextClicked] = useState(false);
|
||||
const timerRef = useRef<number | ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const copyLink = () => {
|
||||
clipboard.copy(props.link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setCheckState(false);
|
||||
}, 1500);
|
||||
setCheckState(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
readOnly
|
||||
label="Link"
|
||||
variant="filled"
|
||||
value={props.link}
|
||||
onClick={() => {
|
||||
if (!textClicked) {
|
||||
copyLink();
|
||||
setTextClicked(true);
|
||||
}
|
||||
}}
|
||||
rightSection={
|
||||
window.isSecureContext && (
|
||||
<ActionIcon onClick={copyLink}>
|
||||
{checkState ? <TbCheck /> : <TbCopy />}
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyTextField;
|
||||
@@ -1,19 +1,10 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { Share } from "../../../types/share.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import CopyTextField from "../CopyTextField";
|
||||
|
||||
const showCompletedUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
@@ -24,40 +15,20 @@ const showCompletedUploadModal = (
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Share ready</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: "Share ready",
|
||||
children: <Body share={share} appUrl={appUrl} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
|
||||
const link = `${appUrl}/share/${share.id}`;
|
||||
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<TextInput
|
||||
readOnly
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
window.isSecureContext && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<CopyTextField link={link} />
|
||||
<Text
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
|
||||
@@ -37,7 +37,7 @@ const showCreateUploadModal = (
|
||||
uploadCallback: (createShare: CreateShare) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
title: "Share",
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
options={options}
|
||||
|
||||
@@ -15,15 +15,14 @@ export async function middleware(request: NextRequest) {
|
||||
const routes = {
|
||||
unauthenticated: new Routes(["/auth/*", "/"]),
|
||||
public: new Routes(["/share/*", "/upload/*"]),
|
||||
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
|
||||
admin: new Routes(["/admin/*"]),
|
||||
account: new Routes(["/account/*"]),
|
||||
account: new Routes(["/account*"]),
|
||||
disabled: new Routes([]),
|
||||
};
|
||||
|
||||
// Get config from backend
|
||||
const config = await (
|
||||
await fetch("http://localhost:8080/api/configs")
|
||||
await fetch(`${request.nextUrl.origin}/api/configs`)
|
||||
).json();
|
||||
|
||||
const getConfig = (key: string) => {
|
||||
@@ -45,15 +44,15 @@ export async function middleware(request: NextRequest) {
|
||||
user = null;
|
||||
}
|
||||
|
||||
if (!getConfig("ALLOW_REGISTRATION")) {
|
||||
if (!getConfig("share.allowRegistration")) {
|
||||
routes.disabled.routes.push("/auth/signUp");
|
||||
}
|
||||
|
||||
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
if (getConfig("share.allowUnauthenticatedShares")) {
|
||||
routes.public.routes = ["*"];
|
||||
}
|
||||
|
||||
if (!getConfig("SMTP_ENABLED")) {
|
||||
if (!getConfig("smtp.enabled")) {
|
||||
routes.disabled.routes.push("/auth/resetPassword*");
|
||||
}
|
||||
|
||||
@@ -63,19 +62,10 @@ export async function middleware(request: NextRequest) {
|
||||
{
|
||||
condition: routes.disabled.contains(route),
|
||||
path: "/",
|
||||
},
|
||||
// Setup status
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
|
||||
path: "/auth/signUp",
|
||||
},
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route),
|
||||
path: user ? "/admin/setup" : "/auth/signIn",
|
||||
},
|
||||
// Authenticated state
|
||||
{
|
||||
condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"),
|
||||
condition: user && routes.unauthenticated.contains(route) && !getConfig("share.allowUnauthenticatedShares"),
|
||||
path: "/upload",
|
||||
},
|
||||
// Unauthenticated state
|
||||
@@ -94,7 +84,7 @@ export async function middleware(request: NextRequest) {
|
||||
},
|
||||
// Home page
|
||||
{
|
||||
condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/",
|
||||
condition: (!getConfig("general.showHomePage") || user) && route == "/",
|
||||
path: "/upload",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React from "react";
|
||||
import {
|
||||
createStyles,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import Meta from "../components/Meta";
|
||||
import Link from "next/link";
|
||||
import Meta from "../components/Meta";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
@@ -21,7 +20,7 @@ const useStyles = createStyles((theme) => ({
|
||||
fontWeight: 900,
|
||||
fontSize: 220,
|
||||
lineHeight: 1,
|
||||
marginBottom: theme.spacing.xl * 1.5,
|
||||
marginBottom: `calc(${theme.spacing.xl} * 100)`,
|
||||
color: theme.colors.gray[2],
|
||||
|
||||
[theme.fn.smallerThan("sm")]: {
|
||||
@@ -32,7 +31,7 @@ const useStyles = createStyles((theme) => ({
|
||||
description: {
|
||||
maxWidth: 500,
|
||||
margin: "auto",
|
||||
marginBottom: theme.spacing.xl * 1.5,
|
||||
marginBottom: `calc(${theme.spacing.xl} * 100)`,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import axios from "axios";
|
||||
import { getCookie, setCookie } from "cookies-next";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import Header from "../components/header/Header";
|
||||
import { ConfigContext } from "../hooks/config.hook";
|
||||
import usePreferences from "../hooks/usePreferences";
|
||||
import { UserContext } from "../hooks/user.hook";
|
||||
@@ -24,17 +26,26 @@ import globalStyle from "../styles/mantine.style";
|
||||
import Config from "../types/config.type";
|
||||
import { CurrentUser } from "../types/user.type";
|
||||
|
||||
const excludeDefaultLayoutRoutes = ["/admin/config/[category]"];
|
||||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const systemTheme = useColorScheme(pageProps.colorScheme);
|
||||
const router = useRouter();
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme);
|
||||
const preferences = usePreferences();
|
||||
|
||||
const [user, setUser] = useState<CurrentUser | null>(pageProps.user);
|
||||
const [route, setRoute] = useState<string>(pageProps.route);
|
||||
|
||||
const [configVariables, setConfigVariables] = useState<Config[]>(
|
||||
pageProps.configVariables
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRoute(router.pathname);
|
||||
}, [router.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
|
||||
}, []);
|
||||
@@ -66,34 +77,39 @@ function App({ Component, pageProps }: AppProps) {
|
||||
toggleColorScheme={toggleColorScheme}
|
||||
>
|
||||
<GlobalStyle />
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<ConfigContext.Provider
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<ConfigContext.Provider
|
||||
value={{
|
||||
configVariables,
|
||||
refresh: async () => {
|
||||
setConfigVariables(await configService.list());
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
configVariables,
|
||||
refresh: async () => {
|
||||
setConfigVariables(await configService.list());
|
||||
user,
|
||||
refreshUser: async () => {
|
||||
const user = await userService.getCurrentUser();
|
||||
setUser(user);
|
||||
return user;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
user,
|
||||
refreshUser: async () => {
|
||||
const user = await userService.getCurrentUser();
|
||||
setUser(user);
|
||||
return user;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
{excludeDefaultLayoutRoutes.includes(route) ? (
|
||||
<Component {...pageProps} />
|
||||
) : (
|
||||
<>
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</UserContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</ModalsProvider>
|
||||
</ColorSchemeProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
@@ -102,27 +118,30 @@ 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 }) => {
|
||||
const { apiURL } = getConfig().serverRuntimeConfig;
|
||||
|
||||
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`, {
|
||||
pageProps.user = await axios(`${apiURL}/api/users/me`, {
|
||||
headers: { cookie: cookieHeader },
|
||||
})
|
||||
.then((res) => res.data)
|
||||
.catch(() => null);
|
||||
|
||||
pageProps.configVariables = (
|
||||
await axios(`http://localhost:8080/api/configs`)
|
||||
).data;
|
||||
pageProps.configVariables = (await axios(`${apiURL}/api/configs`)).data;
|
||||
|
||||
pageProps.route = ctx.req.url;
|
||||
}
|
||||
|
||||
return { pageProps };
|
||||
|
||||
@@ -11,11 +11,9 @@ export default class _Document extends Document {
|
||||
<Html>
|
||||
<Head>
|
||||
<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-128x128.png" />
|
||||
|
||||
<meta property="og:image" content="/img/opengraph-default.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="/img/opengraph-default.png" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#46509e" />
|
||||
</Head>
|
||||
|
||||
@@ -67,7 +67,7 @@ const MyShares = () => {
|
||||
onClick={() =>
|
||||
showCreateReverseShareModal(
|
||||
modals,
|
||||
config.get("SMTP_ENABLED"),
|
||||
config.get("smtp.enabled"),
|
||||
getReverseShares
|
||||
)
|
||||
}
|
||||
@@ -129,9 +129,9 @@ const MyShares = () => {
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("APP_URL")}/share/${
|
||||
share.id
|
||||
}`
|
||||
`${config.get(
|
||||
"general.appUrl"
|
||||
)}/share/${share.id}`
|
||||
);
|
||||
toast.success(
|
||||
"The share link was copied to the keyboard."
|
||||
@@ -140,7 +140,7 @@ const MyShares = () => {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
share.id,
|
||||
config.get("APP_URL")
|
||||
config.get("general.appUrl")
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||