Compare commits

..

30 Commits

Author SHA1 Message Date
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
61 changed files with 4138 additions and 1474 deletions

View File

@@ -1,3 +1,65 @@
## [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)

95
CONTRIBUTING.es.md Normal file
View File

@@ -0,0 +1,95 @@
*Leer esto en otro idioma: [Inglés](CONTRIBUTING.md), [Español](CONTRIBUTING.es.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

@@ -1,3 +1,7 @@
*Read this in another language: [Spanish](CONTRIBUTING.es.md), [English](CONTRIBUTING.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.

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

129
README.es.md Normal file
View File

@@ -0,0 +1,129 @@
# <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](README.es.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.

View File

@@ -1,5 +1,11 @@
# <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](README.es.md), [English](README.md)*
---
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
## ✨ Features
@@ -16,7 +22,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
- [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
@@ -33,7 +39,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 14
- [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
@@ -88,26 +94,31 @@ docker compose up -d
#### Stand-alone
1. Remove the running app
```
pm2 delete pingvin-share-backend pingvin-share-frontend
```
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
```
### Custom branding
#### Name
You can change the name of the app by visiting the admin configuration page and changing the `App Name`.
#### Logo
You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images:
- `logo.png` - The logo in the header and home page
- `favicon.png` - The favicon
- `opengraph.png` - The image used for sharing on social media
- `icons/*` - The icons used for the PWA
You can change the name and the logo of the app by visiting the admin configuration page.
## 🖤 Contribute

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.11.0",
"version": "0.14.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"
}
}

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

@@ -131,15 +131,16 @@ model ShareSecurity {
model Config {
updatedAt DateTime @updatedAt
name String
category String
type String
value String
description 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

@@ -6,7 +6,7 @@ const configVariables: ConfigVariables = {
jwtSecret: {
description: "Long random string used to sign JWT tokens",
type: "string",
value: crypto.randomBytes(256).toString("base64"),
defaultValue: crypto.randomBytes(256).toString("base64"),
locked: true,
},
},
@@ -14,20 +14,20 @@ const configVariables: ConfigVariables = {
appName: {
description: "Name of the application",
type: "string",
value: "Pingvin Share",
defaultValue: "Pingvin Share",
secret: false,
},
appUrl: {
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
defaultValue: "http://localhost:3000",
secret: false,
},
showHomePage: {
description: "Whether to show the home page",
type: "boolean",
value: "true",
defaultValue: "true",
secret: false,
},
},
@@ -35,21 +35,21 @@ const configVariables: ConfigVariables = {
allowRegistration: {
description: "Whether registration is allowed",
type: "boolean",
value: "true",
defaultValue: "true",
secret: false,
},
allowUnauthenticatedShares: {
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
defaultValue: "false",
secret: false,
},
maxSize: {
description: "Maximum share size in bytes",
type: "number",
value: "1073741824",
defaultValue: "1073741824",
secret: false,
},
@@ -59,7 +59,7 @@ const configVariables: ConfigVariables = {
description:
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
type: "boolean",
value: "false",
defaultValue: "false",
secret: false,
},
@@ -67,53 +67,53 @@ const configVariables: ConfigVariables = {
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
defaultValue: "Files shared with you",
},
shareRecipientsMessage: {
description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
"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",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
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",
value: "Reverse share link used",
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",
value:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
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",
value: "Pingvin Share password reset",
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",
value:
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
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",
value: "Pingvin Share invite",
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",
value:
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
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: {
@@ -121,33 +121,33 @@ const configVariables: ConfigVariables = {
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",
defaultValue: "false",
secret: false,
},
host: {
description: "Host of the SMTP server",
type: "string",
value: "",
defaultValue: "",
},
port: {
description: "Port of the SMTP server",
type: "number",
value: "0",
defaultValue: "0",
},
email: {
description: "Email address which the emails get sent from",
type: "string",
value: "",
defaultValue: "",
},
username: {
description: "Username of the SMTP server",
type: "string",
value: "",
defaultValue: "",
},
password: {
description: "Password of the SMTP server",
type: "string",
value: "",
defaultValue: "",
obscured: true,
},
},

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

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

@@ -1,12 +1,17 @@
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";
@@ -16,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
) {}
@@ -51,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

@@ -21,10 +21,12 @@ export class ConfigService {
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 getByCategory(category: string) {
@@ -35,8 +37,9 @@ export class ConfigService {
return configVariables.map((variable) => {
return {
key: `${variable.category}.${variable.name}`,
...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
};
});
}
@@ -48,8 +51,9 @@ export class ConfigService {
return configVariables.map((variable) => {
return {
key: `${variable.category}.${variable.name}`,
...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
};
});
}
@@ -77,7 +81,9 @@ export class ConfigService {
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"
@@ -94,7 +100,7 @@ export class ConfigService {
name: key.split(".")[1],
},
},
data: { value: value.toString() },
data: { value: value ? value.toString() : null },
});
this.configVariables = await this.prisma.config.findMany();

View File

@@ -8,6 +8,9 @@ export class AdminConfigDTO extends ConfigDTO {
@Expose()
secret: boolean;
@Expose()
defaultValue: string;
@Expose()
updatedAt: Date;

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

View File

@@ -1,11 +1,17 @@
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"))
@@ -22,44 +28,63 @@ export class EmailService {
});
}
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("email.enableShareEmailRecipients"))
throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.shareRecipientsSubject"),
text: this.config
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("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.reverseShareSubject"),
text: this.config
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) {
@@ -67,47 +92,42 @@ export class EmailService {
"general.appUrl"
)}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.resetPasswordSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.resetPasswordSubject"),
this.config
.get("email.resetPasswordMessage")
.replaceAll("{url}", resetPasswordUrl),
});
.replaceAll("\\n", "\n")
.replaceAll("{url}", resetPasswordUrl)
);
}
async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.inviteSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.inviteSubject"),
this.config
.get("email.inviteMessage")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password),
});
.replaceAll("{password}", password)
);
}
async sendTestMail(recipientEmail: string) {
try {
await this.getTransporter().sendMail({
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

@@ -142,12 +142,14 @@ 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
);
}
@@ -163,7 +165,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 +174,7 @@ export class ShareService {
});
}
return await this.prisma.share.update({
return this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
});
@@ -195,14 +197,12 @@ export class ShareService {
include: { recipients: 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 +222,7 @@ export class ShareService {
throw new NotFoundException("Share not found");
return {
...share,
hasPassword: share.security?.password ? true : false,
hasPassword: !!share.security?.password,
};
}
@@ -278,7 +278,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) {

View File

@@ -1,6 +1,7 @@
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";
@@ -28,7 +29,7 @@ export class UserSevice {
if (!dto.password) {
const randomPassword = crypto.randomUUID();
hash = await argon.hash(randomPassword);
this.emailService.sendInviteEmail(dto.email, randomPassword);
await this.emailService.sendInviteEmail(dto.email, randomPassword);
} else {
hash = await argon.hash(dto.password);
}

View File

@@ -4,7 +4,15 @@ 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({

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-frontend",
"version": "0.11.0",
"version": "0.14.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

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

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

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

@@ -5,7 +5,6 @@ import {
Stack,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -19,7 +18,7 @@ const showCreateUserModal = (
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Create user</Title>,
title: "Create user",
children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
),
@@ -79,13 +78,12 @@ const Body = ({
})}
/>
)}
{form.values.setPasswordManually ||
(!smtpEnabled && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
))}
{(form.values.setPasswordManually || !smtpEnabled) && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
)}
<Switch
styles={{
body: {

View File

@@ -6,7 +6,6 @@ import {
Stack,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -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} />,
});
};

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

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

@@ -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,6 +35,32 @@ 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("general.appUrl")}/api/shares/${
share.id
@@ -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,158 @@
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) => {
console.log(res.data);
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,4 +1,4 @@
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
import { ActionIcon, Button, Stack, TextInput } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -14,11 +14,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} />,
});
};

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

@@ -1,11 +1,4 @@
import {
ActionIcon,
Button,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ActionIcon, Button, Stack, Text, TextInput } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -24,11 +17,7 @@ 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} />,
});
};

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

@@ -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,7 +6,7 @@ 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";
@@ -76,40 +76,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;
},
}}
>
{excludeDefaultLayoutRoutes.includes(route) ? (
<Component {...pageProps} />
) : (
<>
<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>
);

View File

@@ -12,14 +12,8 @@ export default class _Document extends Document {
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link
rel="apple-touch-icon"
href="/img/icons/icon-white-128x128.png"
/>
<link rel="apple-touch-icon" href="/img/icons/icon-128x128.png" />
<meta property="og:image" content="/img/opengraph.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/img/opengraph.png" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#46509e" />
</Head>

View File

@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput";
import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader";
import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar";
import LogoConfigInput from "../../../components/admin/configuration/LogoConfigInput";
import TestEmailButton from "../../../components/admin/configuration/TestEmailButton";
import CenterLoader from "../../../components/core/CenterLoader";
import Meta from "../../../components/Meta";
@@ -36,30 +37,50 @@ export default function AppShellDemo() {
const isMobile = useMediaQuery("(max-width: 560px)");
const config = useConfig();
const categoryId = router.query.category as string;
const categoryId = (router.query.category as string | undefined) ?? "general";
const [configVariables, setConfigVariables] = useState<AdminConfig[]>();
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
const [logo, setLogo] = useState<File | null>(null);
const saveConfigVariables = async () => {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
config.refresh();
if (logo) {
configService
.changeLogo(logo)
.then(() => {
setLogo(null);
toast.success(
"Logo updated successfully. It may take a few minutes to update on the website."
);
})
.catch(toast.axiosError);
}
if (updatedConfigVariables.length > 0) {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
void config.refresh();
}
};
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key
);
if (index > -1) {
updatedConfigVariables[index] = configVariable;
updatedConfigVariables[index] = {
...updatedConfigVariables[index],
...configVariable,
};
} else {
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
}
@@ -115,9 +136,24 @@ export default function AppShellDemo() {
<Title order={6}>
{configVariableToFriendlyName(configVariable.name)}
</Title>
<Text color="dimmed" size="sm" mb="xs">
{configVariable.description}
</Text>
{configVariable.description.split("\n").length == 1 ? (
<Text color="dimmed" size="sm" mb="xs">
{configVariable.description}
</Text>
) : (
configVariable.description.split("\n").map((line) => (
<Text
key={line}
color="dimmed"
size="sm"
style={{
marginBottom: line === "" ? "1rem" : "0",
}}
>
{line}
</Text>
))
)}
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
@@ -129,6 +165,9 @@ export default function AppShellDemo() {
</Box>
</Group>
))}
{categoryId == "general" && (
<LogoConfigInput logo={logo} setLogo={setLogo} />
)}
</Stack>
<Group mt="lg" position="right">
{categoryId == "smtp" && (

View File

@@ -1,15 +0,0 @@
export function getServerSideProps() {
return {
redirect: {
permanent: false,
destination: "/admin/config/general",
},
props: {},
};
}
const Config = () => {
return null;
};
export default Config;

View File

@@ -41,7 +41,7 @@ const Admin = () => {
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
route: "/admin/config/general",
},
]);

View File

@@ -43,7 +43,7 @@ const Intro = () => {
<Text>Enough talked, have fun with Pingvin Share!</Text>
<Text mt="lg">How to you want to continue?</Text>
<Stack>
<Button href="/admin/config" component={Link}>
<Button href="/admin/config/general" component={Link}>
Customize configuration
</Button>
<Button href="/" component={Link} variant="light">

View File

@@ -20,13 +20,13 @@ const useStyles = createStyles((theme) => ({
inner: {
display: "flex",
justifyContent: "space-between",
paddingTop: theme.spacing.xl * 4,
paddingBottom: theme.spacing.xl * 4,
paddingTop: `calc(${theme.spacing.md} * 4)`,
paddingBottom: `calc(${theme.spacing.md} * 4)`,
},
content: {
maxWidth: 480,
marginRight: theme.spacing.xl * 3,
marginRight: `calc(${theme.spacing.md} * 3)`,
[theme.fn.smallerThan("md")]: {
maxWidth: "100%",

View File

@@ -9,6 +9,7 @@ import showEnterPasswordModal from "../../../components/share/showEnterPasswordM
import showErrorModal from "../../../components/share/showErrorModal";
import shareService from "../../../services/share.service";
import { Share as ShareType } from "../../../types/share.type";
import toast from "../../../utils/toast.util";
export function getServerSideProps(context: GetServerSidePropsContext) {
return {
@@ -28,12 +29,15 @@ const Share = ({ shareId }: { shareId: string }) => {
getFiles();
})
.catch((e) => {
if (e.response.data.error == "share_max_views_exceeded") {
const { error } = e.response.data;
if (error == "share_max_views_exceeded") {
showErrorModal(
modals,
"Visitor limit exceeded",
"The visitor limit from this share has been exceeded."
);
} else {
toast.axiosError(e);
}
});
};
@@ -85,7 +89,12 @@ const Share = ({ shareId }: { shareId: string }) => {
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
</Group>
<FileList files={share?.files} share={share!} isLoading={!share} />
<FileList
files={share?.files}
setShare={setShare}
share={share!}
isLoading={!share}
/>
</>
);
};

View File

@@ -1,94 +0,0 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { GetServerSidePropsContext } from "next";
import { useState } from "react";
export function getServerSideProps(context: GetServerSidePropsContext) {
const { shareId, fileId } = context.params!;
const mimeType = context.query.type as string;
return {
props: { shareId, fileId, mimeType },
};
}
const UnSupportedFile = () => {
return (
<Center style={{ height: "70vh" }}>
<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>
);
};
const FilePreview = ({
shareId,
fileId,
mimeType,
}: {
shareId: string;
fileId: string;
mimeType: string;
}) => {
const [isNotSupported, setIsNotSupported] = useState(false);
if (isNotSupported) return <UnSupportedFile />;
if (mimeType == "application/pdf") {
if (typeof window !== "undefined") {
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
}
return null;
} else if (mimeType.startsWith("video/")) {
return (
<video
width="100%"
controls
onError={() => {
setIsNotSupported(true);
}}
>
<source src={`/api/shares/${shareId}/files/${fileId}?download=false`} />
</video>
);
} else if (mimeType.startsWith("image/")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
onError={() => {
setIsNotSupported(true);
}}
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
alt={`${fileId}_preview`}
width="100%"
/>
);
} else if (mimeType.startsWith("audio/")) {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<audio
controls
style={{ width: "100%" }}
onError={() => {
setIsNotSupported(true);
}}
>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
/>
</audio>
</Stack>
</Center>
);
} else {
return <UnSupportedFile />;
}
};
export default FilePreview;

View File

@@ -59,7 +59,10 @@ const Upload = ({
setFileProgress(1);
const chunks = Math.ceil(file.size / chunkSize);
let chunks = Math.ceil(file.size / chunkSize);
// If the file is 0 bytes, we still need to upload 1 chunk
if (chunks == 0) chunks++;
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
const from = chunkIndex * chunkSize;
@@ -78,7 +81,7 @@ const Upload = ({
name: file.name,
},
chunkIndex,
Math.ceil(file.size / chunkSize)
chunks
)
.then((response) => {
fileId = response.id;
@@ -125,7 +128,7 @@ const Upload = ({
toast.error(
`${fileErrorCount} file(s) failed to upload. Trying again.`,
{
disallowClose: true,
withCloseButton: false,
autoClose: false,
}
);
@@ -173,7 +176,7 @@ const Upload = ({
"share.allowUnauthenticatedShares"
),
enableEmailRecepients: config.get(
"ENABLE_SHARE_EMAIL_RECIPIENTS"
"email.enableShareEmailRecipients"
),
},
uploadFiles

View File

@@ -23,10 +23,12 @@ const get = (key: string, configVariables: Config[]): any => {
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;
};
const finishSetup = async (): Promise<AdminConfig[]> => {
@@ -46,6 +48,12 @@ const isNewReleaseAvailable = async () => {
return response.tag_name.replace("v", "") != process.env.VERSION;
};
const changeLogo = async (file: File) => {
const form = new FormData();
form.append("file", file);
await api.post("/configs/admin/logo", form);
};
export default {
list,
getByCategory,
@@ -54,4 +62,5 @@ export default {
finishSetup,
sendTestEmail,
isNewReleaseAvailable,
changeLogo,
};

View File

@@ -53,7 +53,7 @@ const isShareIdAvailable = async (id: string): Promise<boolean> => {
};
const doesFileSupportPreview = (fileName: string) => {
const mimeType = mime.contentType(fileName);
const mimeType = (mime.contentType(fileName) || "").split(";")[0];
if (!mimeType) return false;
@@ -61,6 +61,7 @@ const doesFileSupportPreview = (fileName: string) => {
mimeType.startsWith("video/"),
mimeType.startsWith("image/"),
mimeType.startsWith("audio/"),
mimeType.startsWith("text/"),
mimeType == "application/pdf",
];

View File

@@ -16,4 +16,14 @@ export default <MantineThemeOverride>{
],
},
primaryColor: "victoria",
components: {
Modal: {
styles: (theme) => ({
title: {
fontSize: theme.fontSizes.lg,
fontWeight: 700,
},
}),
},
},
};

View File

@@ -1,5 +1,6 @@
type Config = {
key: string;
defaultValue: string;
value: string;
type: string;
};

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share",
"version": "0.11.0",
"version": "0.14.0",
"scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",