Compare commits

...

61 Commits

Author SHA1 Message Date
Elias Schneider
adf0f8d57e release: 0.16.0 2023-07-09 17:15:26 +02:00
Elias Schneider
447c86f1c9 chore: remove backend Dockerfile 2023-06-28 15:45:54 +02:00
pierrbt
1466240461 feat: Adding more informations on My Shares page (table and modal) (#174)
* Adding an information button to the shares and corrected MyShare interface

* Adding other informations and disk usage

* Adding description, disk usage

* Add case if the expiration is never

* Adding file size and better UI

* UI changes to Information Modal

* Adding description to the My Shares page

* Ran format

* Remove string type

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove string type check

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove string type conversion

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Variable name changes

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove color

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Requested changes made

* Ran format

* Adding MediaQuery

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-06-26 08:22:15 +02:00
pierrbt
348852cfa4 feat: Adding the possibility of copying the link by clicking text and icons (#171) 2023-06-23 20:07:49 +02:00
Elias Schneider
932496a121 release: 0.15.0 2023-05-09 09:18:31 +02:00
Elias Schneider
0c7b2a8e70 docs: add environment variables to the README 2023-05-09 09:18:02 +02:00
Elias Schneider
1df5c7123e feat: allow to configure clamav with environment variables 2023-05-09 08:45:56 +02:00
Elias Schneider
2dc0fc9332 refactor: improve logging 2023-05-09 08:45:30 +02:00
Elias Schneider
98c0de78e8 feat: add env variables for port, database url and data dir 2023-05-05 11:37:02 +02:00
Elias Schneider
5132d177b8 feat: add healthcheck endpoint 2023-04-27 22:31:06 +02:00
Elias Schneider
e5071cba12 feat: configure ports, db url and api url with env variables 2023-04-25 23:39:57 +02:00
Elias Schneider
b33c1d7f4b release: 0.14.1 2023-04-07 23:13:54 +02:00
Elias Schneider
39a74510c1 fix: boolean config variables can't be set to false 2023-04-07 23:13:44 +02:00
Elias Schneider
b7db9b9b40 refactor: simplify create share function 2023-04-04 22:47:32 +02:00
Elias Schneider
2ca0092b71 docs: fix translation path 2023-04-02 18:55:41 +02:00
Elias Schneider
b4bf43910e docs: move translated docs in docs folder 2023-04-02 18:53:54 +02:00
AC6
90aa919694 docs: add Simplified Chinese version of README and CONTRIBUTING (#139)
* add simplified Chinese translation for README.md

* add simplified Chinese translation for CONTRIBUTING.md
2023-04-02 18:49:03 +02:00
Elias Schneider
f2e4019190 release: 0.14.0 2023-04-01 20:19:27 +02:00
Rooyca
ffd4e43f11 docs: add Spanish version of README and CONTRIBUTING (#138)
* doc: add Spanish version of README and CONTRIBUTING

* docs: change h3 tag from language switch to normal size
2023-04-01 20:15:47 +02:00
Elias Schneider
0e5c673270 fix: bool config variable can't be changed 2023-03-24 21:37:39 +01:00
iUnstable0
beece56327 feat(share, config): more variables, placeholder and reset default (#132)
* More email share vars + unfinished placeolders config

{desc} {expires} vars
(unfinished) config placeholder vals

* done

* migrate

* edit seed

* removed comments

* refactor: replace dependecy `luxon` with `moment`

* update shareRecipientsMessage message

* chore: remove `luxon`

* fix: grammatically incorrect `shareRecipientsMessage` message

* changed to defaultValue and value instead

* fix: don't expose defaultValue to non admin user

* fix: update default value if default value changes

* refactor: set config value to null instead of a empty string

* refactor: merge two migrations into one

* fix value check empty

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-03-23 08:31:21 +01:00
iUnstable0
a0d1d98e24 docs: improve stand-alone upgrade guide (#128)
* Update README.md

* docs: improve stand-alone upgrade guide

* Update README.md
2023-03-16 09:21:53 +01:00
Elias Schneider
ca73ccf629 release: 0.13.1 2023-03-14 20:26:04 +01:00
Elias Schneider
9f2097e788 fix: empty file can't be uploaded in chrome 2023-03-14 20:24:21 +01:00
Elias Schneider
2158df4228 release: 0.13.0 2023-03-14 16:09:20 +01:00
Elias Schneider
37e765ddc7 fix: show line breaks in txt preview 2023-03-14 16:08:57 +01:00
Elias Schneider
a91c531642 docs: update main screenshot 2023-03-14 15:47:42 +01:00
Elias Schneider
5a7f7ca2f6 chore: dump node js version 2023-03-14 15:36:35 +01:00
Elias Schneider
813ee4de2c refactor: rename deprecated Prisma imports 2023-03-14 15:11:24 +01:00
Elias Schneider
b25c30d1ed feat: sort shared files 2023-03-14 14:50:18 +01:00
Elias Schneider
c807d208d8 feat: add preview modal 2023-03-14 12:09:21 +01:00
Elias Schneider
f82099f36e fix: upload file if it is 0 bytes 2023-03-13 08:57:56 +01:00
Elias Schneider
6345e21db9 refactor: globalize modal title style 2023-03-13 08:50:54 +01:00
Elias Schneider
f55aa80516 fix: replace "pingvin share" with dynamic app name 2023-03-12 20:13:55 +01:00
Elias Schneider
0ce8b528e1 refactor: improve error handling for failed emails 2023-03-12 19:29:39 +01:00
Elias Schneider
8ff417a013 fix: set password manually input not shown 2023-03-12 19:28:50 +01:00
Elias Schneider
cb1a0d4090 release: 0.12.1 2023-03-11 12:40:27 +01:00
Elias Schneider
753dbe83b7 fix: 48px icon does not update 2023-03-11 12:33:22 +01:00
Elias Schneider
0c2a62b0ca release: 0.12.0 2023-03-10 09:40:19 +01:00
Elias Schneider
452c635933 chore: dump packages 2023-03-10 09:40:09 +01:00
Elias Schneider
0455ba1bc1 chore: upgrade mantine to v6 2023-03-10 09:01:33 +01:00
Elias Schneider
3ad6b03b6b fix: home page shown even if disabled 2023-03-10 08:40:32 +01:00
Elias Schneider
91c3525b15 chore: add sharp for image optimizations 2023-03-08 17:47:36 +01:00
Elias Schneider
8403d7e14d feat: ability to change logo in frontend 2023-03-08 14:47:41 +01:00
Elias Schneider
8f71fd3435 fix: crypto is not defined 2023-03-08 13:10:10 +01:00
Elias Schneider
155c743197 release: 0.11.1 2023-03-05 10:50:32 +01:00
Elias Schneider
8b77e81d4c fix: old config variable prevents to create a share 2023-03-05 10:48:01 +01:00
Elias Schneider
22d81b2220 release: 0.11.0 2023-03-04 23:41:11 +01:00
Elias Schneider
0317f3a508 fix: frontend error when user deleted 2023-03-04 23:40:02 +01:00
Elias Schneider
fddad3ef70 feat: custom branding (#112)
* add first concept

* remove setup status

* split config page in multiple components

* add custom branding docs

* add test email button

* fix invalid email from header

* add migration

* mount images to host

* update docs

* remove unused endpoint

* run formatter
2023-03-04 23:29:00 +01:00
Elias Schneider
f9840505b8 feat: invite new user with email 2023-02-21 08:51:04 +01:00
Elias Schneider
759c55f625 docs: fix remove app before upgrading 2023-02-13 10:09:53 +01:00
Elias Schneider
edb511252f release: 0.10.2 2023-02-13 09:39:43 +01:00
Elias Schneider
c3af0fe097 fix: pdf preview tries to render on server 2023-02-13 09:39:27 +01:00
Elias Schneider
6419da07fb release: 0.10.1 2023-02-12 20:00:55 +01:00
Elias Schneider
7cd9dff637 fix: setup wizard doesn't redirect after completion 2023-02-12 20:00:35 +01:00
Elias Schneider
2a826f7941 docs: stand-alone installation start backend first 2023-02-12 19:04:12 +01:00
Elias Schneider
8720232755 chore: add question issue template 2023-02-12 14:40:10 +01:00
Elias Schneider
dc8cf3d5ca fix: non administrator user redirection error while setup isn't finished 2023-02-11 15:57:21 +01:00
Elias Schneider
979b882150 docs: add stand-alone installation guide 2023-02-10 14:59:19 +01:00
Elias Schneider
c55019f71b docs: improve contributing guideline 2023-02-10 14:22:32 +01:00
122 changed files with 5699 additions and 2141 deletions

17
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: ❓ Question
description: "Submit a question"
title: "❓ Question:"
labels: [question]
body:
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: "🙋‍♂️ Question"
description: "A clear question. Please provide as much detail as possible."
placeholder: "How do I ...?"
- type: markdown
attributes:
value: |
Before submitting, please check if the question hasn't been asked before.

3
.gitignore vendored
View File

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

@@ -0,0 +1 @@
/backend/src/constants.ts

View File

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

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,94 @@
/*
Warnings:
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL,
PRIMARY KEY ("name", "category")
);
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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
View 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) 来提交你的贡献

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 944 B

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>

Before

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import {
Burger,
Button,
Group,
Header,
MediaQuery,
Text,
useMantineTheme,
} from "@mantine/core";
import Link from "next/link";
import { Dispatch, SetStateAction } from "react";
import useConfig from "../../../hooks/config.hook";
import Logo from "../../Logo";
const ConfigurationHeader = ({
isMobileNavBarOpened,
setIsMobileNavBarOpened,
}: {
isMobileNavBarOpened: boolean;
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
}) => {
const config = useConfig();
const theme = useMantineTheme();
return (
<Header height={60} p="md">
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
<Burger
opened={isMobileNavBarOpened}
onClick={() => setIsMobileNavBarOpened((o) => !o)}
size="sm"
color={theme.colors.gray[6]}
mr="xl"
/>
</MediaQuery>
<Group position="apart" w="100%">
<Link href="/" passHref>
<Group>
<Logo height={35} width={35} />
<Text weight={600}>{config.get("general.appName")}</Text>
</Group>
</Link>
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
<Button variant="light" component={Link} href="/admin">
Go back
</Button>
</MediaQuery>
</Group>
</div>
</Header>
);
};
export default ConfigurationHeader;

View File

@@ -0,0 +1,97 @@
import {
Box,
Button,
createStyles,
Group,
MediaQuery,
Navbar,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import Link from "next/link";
import { Dispatch, SetStateAction } from "react";
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
const categories = [
{ name: "General", icon: <TbSquare /> },
{ name: "Email", icon: <TbMail /> },
{ name: "Share", icon: <TbShare /> },
{ name: "SMTP", icon: <TbAt /> },
];
const useStyles = createStyles((theme) => ({
activeLink: {
backgroundColor: theme.fn.variant({
variant: "light",
color: theme.primaryColor,
}).background,
color: theme.fn.variant({ variant: "light", color: theme.primaryColor })
.color,
borderRadius: theme.radius.sm,
fontWeight: 600,
},
}));
const ConfigurationNavBar = ({
categoryId,
isMobileNavBarOpened,
setIsMobileNavBarOpened,
}: {
categoryId: string;
isMobileNavBarOpened: boolean;
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
}) => {
const { classes } = useStyles();
return (
<Navbar
p="md"
hiddenBreakpoint="sm"
hidden={!isMobileNavBarOpened}
width={{ sm: 200, lg: 300 }}
>
<Navbar.Section>
<Text size="xs" color="dimmed" mb="sm">
Configuration
</Text>
<Stack spacing="xs">
{categories.map((category) => (
<Box
p="xs"
component={Link}
onClick={() => setIsMobileNavBarOpened(false)}
className={
categoryId == category.name.toLowerCase()
? classes.activeLink
: undefined
}
key={category.name}
href={`/admin/config/${category.name.toLowerCase()}`}
>
<Group>
<ThemeIcon
variant={
categoryId == category.name.toLowerCase()
? "filled"
: "light"
}
>
{category.icon}
</ThemeIcon>
<Text size="sm">{category.name}</Text>
</Group>
</Box>
))}
</Stack>
</Navbar.Section>
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
<Button mt="xl" variant="light" component={Link} href="/admin">
Go back
</Button>
</MediaQuery>
</Navbar>
);
};
export default ConfigurationNavBar;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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