Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f1be2faf | ||
|
|
57be6945f2 | ||
|
|
82abe52ea5 | ||
|
|
6fa7af7905 | ||
|
|
13e7a30bb9 | ||
|
|
955af04e32 | ||
|
|
035e67f759 | ||
|
|
167ec782ef | ||
|
|
743c33475f | ||
|
|
3f1d3b7833 | ||
|
|
3d76e41cd8 | ||
|
|
e9efbc17bc | ||
|
|
307d176430 | ||
|
|
7e24ba9721 | ||
|
|
f9774d82d8 | ||
|
|
7647a9f620 | ||
|
|
d4e8d4f58b | ||
|
|
4df8dea5cc | ||
|
|
84aa100f84 | ||
|
|
bddb87b9b3 | ||
|
|
18c10c0ac6 | ||
|
|
f02e2979c4 | ||
|
|
7b34cb14cb | ||
|
|
019ef090ac | ||
|
|
7304b54125 | ||
|
|
ea0d5216e8 | ||
|
|
62deb6c152 | ||
|
|
9ba2b4c82c | ||
|
|
a47d080657 | ||
|
|
72a52eb33f | ||
|
|
c9a2a469c6 | ||
|
|
b534129194 | ||
|
|
0beebfd779 | ||
|
|
2ed5ecc1ea | ||
|
|
9bb05158c5 | ||
|
|
36230371fd | ||
|
|
5fd79a35cb | ||
|
|
cecaa90e15 | ||
|
|
2584bb0d48 | ||
|
|
82008aa261 | ||
|
|
a07a78a138 | ||
|
|
2618bbb897 | ||
|
|
6667c7a8d7 | ||
|
|
7f0c31c2e0 | ||
|
|
3165dcf9e6 | ||
|
|
f4c88aeb08 | ||
|
|
231a2e95b9 | ||
|
|
7827b687fa | ||
|
|
389dc87cac | ||
|
|
5816b39fc6 | ||
|
|
890588f5da | ||
|
|
e6a2014875 | ||
|
|
396363488c | ||
|
|
424331ed1a | ||
|
|
d198a132db | ||
|
|
a041a6969d | ||
|
|
be57bd3354 | ||
|
|
70b425b380 | ||
|
|
8259eb286c | ||
|
|
7071d8bd87 | ||
|
|
b2ed7b74c0 | ||
|
|
b9f6e3bd08 | ||
|
|
7c5ec8d0ea | ||
|
|
0276294f52 | ||
|
|
7574eb3191 | ||
|
|
a1ea7c0265 | ||
|
|
adf0f8d57e | ||
|
|
447c86f1c9 | ||
|
|
1466240461 | ||
|
|
348852cfa4 | ||
|
|
932496a121 | ||
|
|
0c7b2a8e70 | ||
|
|
1df5c7123e | ||
|
|
2dc0fc9332 | ||
|
|
98c0de78e8 | ||
|
|
5132d177b8 | ||
|
|
e5071cba12 | ||
|
|
b33c1d7f4b | ||
|
|
39a74510c1 | ||
|
|
b7db9b9b40 | ||
|
|
2ca0092b71 | ||
|
|
b4bf43910e | ||
|
|
90aa919694 | ||
|
|
f2e4019190 | ||
|
|
ffd4e43f11 | ||
|
|
0e5c673270 | ||
|
|
beece56327 | ||
|
|
a0d1d98e24 | ||
|
|
ca73ccf629 | ||
|
|
9f2097e788 | ||
|
|
2158df4228 | ||
|
|
37e765ddc7 | ||
|
|
a91c531642 | ||
|
|
5a7f7ca2f6 | ||
|
|
813ee4de2c | ||
|
|
b25c30d1ed | ||
|
|
c807d208d8 | ||
|
|
f82099f36e | ||
|
|
6345e21db9 | ||
|
|
f55aa80516 | ||
|
|
0ce8b528e1 | ||
|
|
8ff417a013 | ||
|
|
cb1a0d4090 | ||
|
|
753dbe83b7 | ||
|
|
0c2a62b0ca | ||
|
|
452c635933 | ||
|
|
0455ba1bc1 | ||
|
|
3ad6b03b6b | ||
|
|
91c3525b15 | ||
|
|
8403d7e14d | ||
|
|
8f71fd3435 |
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
Normal file
19
.github/ISSUE_TEMPLATE/language-request.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
name: "🌐 Language request"
|
||||||
|
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||||
|
title: "🌐 Language request: <language name in english>"
|
||||||
|
labels: [language-request]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: language-name-native
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Language name (native)"
|
||||||
|
placeholder: "Schweizerdeutsch"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: language-code
|
||||||
|
attributes:
|
||||||
|
label: "🌐 Language code"
|
||||||
|
placeholder: "de-CH"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
28
.github/workflows/build-docker-image.yml
vendored
28
.github/workflows/build-docker-image.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Create Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -10,15 +10,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: checkout code
|
- name: checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: login to docker registry
|
|
||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
- name: Login to Docker registry
|
||||||
- name: Build the image
|
uses: docker/login-action@v2
|
||||||
run: |
|
with:
|
||||||
docker buildx build --push \
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
--tag stonith404/pingvin-share:latest \
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
--tag stonith404/pingvin-share:${{ github.ref_name }} \
|
|
||||||
--platform linux/amd64,linux/arm64 .
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: stonith404/pingvin-share:latest,stonith404/pingvin-share:${{ github.ref_name }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
!/backend/prisma/.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
166
CHANGELOG.md
166
CHANGELOG.md
@@ -1,3 +1,169 @@
|
|||||||
|
## [0.18.0](https://github.com/stonith404/pingvin-share/compare/v0.17.5...v0.18.0) (2023-09-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* show upload modal on file drop ([13e7a30](https://github.com/stonith404/pingvin-share/commit/13e7a30bb96faeb25936ff08a107834fd7af5766))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **docker:** Updated to newest version of alpine linux and fixed missing dependencies ([#255](https://github.com/stonith404/pingvin-share/issues/255)) ([6fa7af7](https://github.com/stonith404/pingvin-share/commit/6fa7af79051c964060bd291c9faad90fc01a1b72))
|
||||||
|
* nextjs proxy warning ([e9efbc1](https://github.com/stonith404/pingvin-share/commit/e9efbc17bcf4827e935e2018dcdf3b70a9a49991))
|
||||||
|
|
||||||
|
## [0.17.5](https://github.com/stonith404/pingvin-share/compare/v0.17.4...v0.17.5) (2023-09-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **localization:** Added thai language ([#231](https://github.com/stonith404/pingvin-share/issues/231)) ([bddb87b](https://github.com/stonith404/pingvin-share/commit/bddb87b9b3ec5426a3c7a14a96caf2eb45b93ff7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* autocomplete on create share modal ([d4e8d4f](https://github.com/stonith404/pingvin-share/commit/d4e8d4f58b9b7d10b865eff49aa784547891c4e8))
|
||||||
|
* missing translation ([7647a9f](https://github.com/stonith404/pingvin-share/commit/7647a9f620cbc5d38e019225a680a53bd3027698))
|
||||||
|
|
||||||
|
## [0.17.4](https://github.com/stonith404/pingvin-share/compare/v0.17.3...v0.17.4) (2023-08-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* redirection to `localhost:3000` ([ea0d521](https://github.com/stonith404/pingvin-share/commit/ea0d5216e89346b8d3ef0277b76fdc6302e9de15))
|
||||||
|
|
||||||
|
## [0.17.3](https://github.com/stonith404/pingvin-share/compare/v0.17.2...v0.17.3) (2023-07-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* logo doesn't get loaded correctly ([9ba2b4c](https://github.com/stonith404/pingvin-share/commit/9ba2b4c82cdad9097b33f0451771818c7b972a6b))
|
||||||
|
* share expiration never doesn't work if using another language than English ([a47d080](https://github.com/stonith404/pingvin-share/commit/a47d080657e1d08ef06ec7425d8bdafd5a26c24a))
|
||||||
|
|
||||||
|
## [0.17.2](https://github.com/stonith404/pingvin-share/compare/v0.17.1...v0.17.2) (2023-07-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* `ECONNREFUSED` with Docker ipv6 enabled ([c9a2a46](https://github.com/stonith404/pingvin-share/commit/c9a2a469c67d3c3cd08179b44e2bf82208f05177))
|
||||||
|
|
||||||
|
## [0.17.1](https://github.com/stonith404/pingvin-share/compare/v0.17.0...v0.17.1) (2023-07-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* rename pt-PT.ts to pt-BR.ts ([2584bb0](https://github.com/stonith404/pingvin-share/commit/2584bb0d48c761940eafc03d5cd98d47e7a5b0ae))
|
||||||
|
|
||||||
|
## [0.17.0](https://github.com/stonith404/pingvin-share/compare/v0.16.1...v0.17.0) (2023-07-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* ability to define zip compression level ([7827b68](https://github.com/stonith404/pingvin-share/commit/7827b687fa022e86a2643e7a1951af8c7e80608c))
|
||||||
|
* add note to language picker ([7f0c31c](https://github.com/stonith404/pingvin-share/commit/7f0c31c2e09b3ee9aae6c3dfb54fac2f2b1dfe23))
|
||||||
|
* add share url alias `/s` ([231a2e9](https://github.com/stonith404/pingvin-share/commit/231a2e95b9734cf4704454e1945698753dbb378b))
|
||||||
|
* localization ([#196](https://github.com/stonith404/pingvin-share/issues/196)) ([b9f6e3b](https://github.com/stonith404/pingvin-share/commit/b9f6e3bd08dcfc050048fba582b35958bc7b6184))
|
||||||
|
* update default value of `maxSize` from `1073741824` to `1000000000` ([389dc87](https://github.com/stonith404/pingvin-share/commit/389dc87cac775d916d0cff9b71d3c5ff90bfe916))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* confusion between GB and GiB ([5816b39](https://github.com/stonith404/pingvin-share/commit/5816b39fc6ef6fe6b7cf8e7925aa297561f5b796))
|
||||||
|
* mistakes in English translations ([70b425b](https://github.com/stonith404/pingvin-share/commit/70b425b3807be79a3b518cc478996c71dffcf986))
|
||||||
|
* wrong layout if button text is too long in modals ([f4c88ae](https://github.com/stonith404/pingvin-share/commit/f4c88aeb0823c2c18535c25fcf8e16afa8b53a56))
|
||||||
|
|
||||||
|
### [0.16.1](https://github.com/stonith404/pingvin-share/compare/v0.16.0...v0.16.1) (2023-07-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Adding reverse share ability to copy the link ([#191](https://github.com/stonith404/pingvin-share/issues/191)) ([7574eb3](https://github.com/stonith404/pingvin-share/commit/7574eb3191f21aadd64f436e9e7c78d3e3973a07)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
||||||
|
* Adding reverse shares' shares a clickable link ([#190](https://github.com/stonith404/pingvin-share/issues/190)) ([0276294](https://github.com/stonith404/pingvin-share/commit/0276294f5219a7edcc762bc52391b6720cfd741d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* set link default value to random ([#192](https://github.com/stonith404/pingvin-share/issues/192)) ([a1ea7c0](https://github.com/stonith404/pingvin-share/commit/a1ea7c026594a54eafd52f764eecbf06e1bb4d4e)), closes [#178](https://github.com/stonith404/pingvin-share/issues/178) [#181](https://github.com/stonith404/pingvin-share/issues/181)
|
||||||
|
|
||||||
|
## [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)
|
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# 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.
|
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.
|
||||||
|
|||||||
45
Dockerfile
45
Dockerfile
@@ -1,37 +1,45 @@
|
|||||||
# Using node slim because prisma ORM needs libc for ARM builds
|
# Stage 1: Frontend dependencies
|
||||||
|
FROM node:20-alpine AS frontend-dependencies
|
||||||
# Stage 1: on frontend dependency change
|
|
||||||
FROM node:18-slim AS frontend-dependencies
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Stage 2: on frontend change
|
# Stage 2: Build frontend
|
||||||
FROM node:18-slim AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY ./frontend .
|
COPY ./frontend .
|
||||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: on backend dependency change
|
# Stage 3: Backend dependencies
|
||||||
FROM node:18-slim AS backend-dependencies
|
FROM node:20-alpine AS backend-dependencies
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY backend/package.json backend/package-lock.json ./
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Stage 4:on backend change
|
# Stage 4: Build backend
|
||||||
FROM node:18-slim AS backend-builder
|
FROM node:20-alpine AS backend-builder
|
||||||
RUN apt-get update && apt-get install -y openssl
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY ./backend .
|
COPY ./backend .
|
||||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build && npm prune --production
|
RUN npm run build && npm prune --production
|
||||||
|
|
||||||
# Stage 5: Final image
|
# Stage 5: Final image
|
||||||
FROM node:18-slim AS runner
|
FROM node:20-alpine AS runner
|
||||||
ENV NODE_ENV=docker
|
ENV NODE_ENV=docker
|
||||||
RUN apt-get update && apt-get install -y openssl
|
|
||||||
|
# Alpine specific dependencies
|
||||||
|
RUN apk update --no-cache
|
||||||
|
RUN apk upgrade --no-cache
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Set user and group IDs for the node user
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
RUN deluser node
|
||||||
|
RUN adduser -u $UID -g $GID node -D
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /opt/app/public ./public
|
COPY --from=frontend-builder /opt/app/public ./public
|
||||||
@@ -46,5 +54,12 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
|
|||||||
COPY --from=backend-builder /opt/app/package.json ./
|
COPY --from=backend-builder /opt/app/package.json ./
|
||||||
|
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod
|
|
||||||
|
# Add a health check to ensure the container is healthy
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
# Application startup
|
||||||
|
# HOSTNAME=0.0.0.0 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed
|
||||||
|
CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
|
||||||
74
README.md
74
README.md
@@ -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>
|
# <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.
|
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ 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)
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
- [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
|
## ⌨️ Setup
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
|
|||||||
|
|
||||||
Required tools:
|
Required tools:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/) >= 14
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||||
|
|
||||||
@@ -88,27 +94,65 @@ docker compose up -d
|
|||||||
|
|
||||||
#### Stand-alone
|
#### Stand-alone
|
||||||
|
|
||||||
1. Remove the running app
|
1. Stop the running app
|
||||||
```
|
```bash
|
||||||
pm2 delete pingvin-share-backend pingvin-share-frontend
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
```
|
```
|
||||||
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||||
|
|
||||||
### Custom branding
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
#### Name
|
# Checkout the latest version
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
You can change the name of the app by visiting the admin configuration page and changing the `App Name`.
|
# Start the backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
#### Logo
|
# Start the frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
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:
|
### Configuration
|
||||||
|
|
||||||
- `logo.png` - The logo in the header and home page
|
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||||
- `favicon.png` - The favicon
|
|
||||||
- `opengraph.png` - The image used for sharing on social media
|
#### Environment variables
|
||||||
- `icons/*` - The icons used for the PWA
|
|
||||||
|
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
|
## 🖤 Contribute
|
||||||
|
|
||||||
You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started.
|
### Translations
|
||||||
|
|
||||||
|
You can help to translate Pingvin Share into your language.
|
||||||
|
On [Crowdin](https://crowdin.com/project/pingvin-share) you can easily translate Pingvin Share online.
|
||||||
|
|
||||||
|
Is your language not on Crowdin? Feel free to [Request it](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E).
|
||||||
|
|
||||||
|
Any issues while translating? Feel free to participate in the [Localization discussion](https://github.com/stonith404/pingvin-share/discussions/198).
|
||||||
|
|
||||||
|
### Project
|
||||||
|
|
||||||
|
You're very welcome to contribute to Pingvin Share! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.
|
||||||
|
|||||||
1
backend/.prettierignore
Normal file
1
backend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/src/constants.ts
|
||||||
@@ -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
|
|
||||||
5119
backend/package-lock.json
generated
5119
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.11.1",
|
"version": "0.18.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --end-of-line=auto --write 'src/**/*.ts'",
|
||||||
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^10.1.2",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^9.2.1",
|
"@nestjs/core": "^10.1.2",
|
||||||
"@nestjs/jwt": "^10.0.1",
|
"@nestjs/jwt": "^10.1.0",
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
"@nestjs/platform-express": "^10.1.2",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^3.0.1",
|
||||||
"@nestjs/swagger": "^6.2.1",
|
"@nestjs/swagger": "^7.1.4",
|
||||||
"@nestjs/throttler": "^3.1.0",
|
"@nestjs/throttler": "^4.2.1",
|
||||||
"@prisma/client": "^4.8.1",
|
"@prisma/client": "^5.0.0",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"argon2": "^0.30.3",
|
"argon2": "^0.30.3",
|
||||||
"body-parser": "^1.20.1",
|
"body-parser": "^1.20.2",
|
||||||
"clamscan": "^2.1.2",
|
"clamscan": "^2.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.14.0",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^4.0.4",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.32.4",
|
||||||
"ts-node": "^10.9.1"
|
"ts-node": "^10.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.1.8",
|
"@nestjs/cli": "^10.1.10",
|
||||||
"@nestjs/schematics": "^9.0.4",
|
"@nestjs/schematics": "^10.0.1",
|
||||||
"@nestjs/testing": "^9.2.1",
|
"@nestjs/testing": "^10.1.2",
|
||||||
"@types/archiver": "^5.3.1",
|
"@types/archiver": "^5.3.2",
|
||||||
"@types/clamscan": "^2.0.4",
|
"@types/clamscan": "^2.0.4",
|
||||||
"@types/cookie-parser": "^1.4.3",
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.1",
|
||||||
"@types/express": "^4.17.15",
|
"@types/express": "^4.17.17",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "^18.11.18",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/nodemailer": "^6.4.7",
|
"@types/node": "^20.4.5",
|
||||||
"@types/passport-jwt": "^3.0.8",
|
"@types/nodemailer": "^6.4.9",
|
||||||
|
"@types/passport-jwt": "^3.0.9",
|
||||||
"@types/qrcode-svg": "^1.1.1",
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||||
"@typescript-eslint/parser": "^5.48.1",
|
"@typescript-eslint/parser": "^6.2.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.31.0",
|
"eslint": "^8.46.0",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.9.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"newman": "^5.3.2",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^2.8.2",
|
"prettier": "^3.0.0",
|
||||||
"prisma": "^4.9.0",
|
"prisma": "^5.0.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.2",
|
"ts-loader": "^9.4.4",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^5.1.6",
|
||||||
"wait-on": "^7.0.1"
|
"wait-on": "^7.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
backend/prisma/.env
Normal file
2
backend/prisma/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#This file is only used to set a default value for the database url
|
||||||
|
DATABASE_URL="file:../data/pingvin-share.db"
|
||||||
@@ -0,0 +1,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;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `description` on the `Config` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- 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,
|
||||||
|
"defaultValue" TEXT NOT NULL DEFAULT '',
|
||||||
|
"value" TEXT,
|
||||||
|
"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", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "defaultValue", "locked", "name", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
|
||||||
|
DROP TABLE "Config";
|
||||||
|
ALTER TABLE "new_Config" RENAME TO "Config";
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -4,7 +4,7 @@ generator client {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = "file:../data/pingvin-share.db"
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -131,15 +131,15 @@ model ShareSecurity {
|
|||||||
model Config {
|
model Config {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
name String
|
name String
|
||||||
category String
|
category String
|
||||||
type String
|
type String
|
||||||
value String
|
defaultValue String @default("")
|
||||||
description String
|
value String?
|
||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
order Int
|
order Int
|
||||||
|
|
||||||
@@id([name, category])
|
@@id([name, category])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,150 +4,118 @@ import * as crypto from "crypto";
|
|||||||
const configVariables: ConfigVariables = {
|
const configVariables: ConfigVariables = {
|
||||||
internal: {
|
internal: {
|
||||||
jwtSecret: {
|
jwtSecret: {
|
||||||
description: "Long random string used to sign JWT tokens",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: crypto.randomBytes(256).toString("base64"),
|
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
appName: {
|
appName: {
|
||||||
description: "Name of the application",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Pingvin Share",
|
defaultValue: "Pingvin Share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
appUrl: {
|
appUrl: {
|
||||||
description: "On which URL Pingvin Share is available",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "http://localhost:3000",
|
defaultValue: "http://localhost:3000",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
showHomePage: {
|
showHomePage: {
|
||||||
description: "Whether to show the home page",
|
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "true",
|
defaultValue: "true",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
share: {
|
share: {
|
||||||
allowRegistration: {
|
allowRegistration: {
|
||||||
description: "Whether registration is allowed",
|
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "true",
|
defaultValue: "true",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
allowUnauthenticatedShares: {
|
allowUnauthenticatedShares: {
|
||||||
description: "Whether unauthorized users can create shares",
|
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "false",
|
defaultValue: "false",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
maxSize: {
|
maxSize: {
|
||||||
description: "Maximum share size in bytes",
|
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "1073741824",
|
defaultValue: "1000000000",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
zipCompressionLevel: {
|
||||||
|
type: "number",
|
||||||
|
defaultValue: "9",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
enableShareEmailRecipients: {
|
enableShareEmailRecipients: {
|
||||||
description:
|
|
||||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "false",
|
defaultValue: "false",
|
||||||
|
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
shareRecipientsSubject: {
|
shareRecipientsSubject: {
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent to the share recipients.",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Files shared with you",
|
defaultValue: "Files shared with you",
|
||||||
},
|
},
|
||||||
shareRecipientsMessage: {
|
shareRecipientsMessage: {
|
||||||
description:
|
|
||||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
defaultValue:
|
||||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
"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: {
|
reverseShareSubject: {
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Reverse share link used",
|
defaultValue: "Reverse share link used",
|
||||||
},
|
},
|
||||||
reverseShareMessage: {
|
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",
|
type: "text",
|
||||||
value:
|
defaultValue:
|
||||||
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||||
},
|
},
|
||||||
resetPasswordSubject: {
|
resetPasswordSubject: {
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent when a user requests a password reset.",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Pingvin Share password reset",
|
defaultValue: "Pingvin Share password reset",
|
||||||
},
|
},
|
||||||
resetPasswordMessage: {
|
resetPasswordMessage: {
|
||||||
description:
|
|
||||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
defaultValue:
|
||||||
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
"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: {
|
inviteSubject: {
|
||||||
description:
|
|
||||||
"Subject of the email which gets sent when an admin invites an user.",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "Pingvin Share invite",
|
defaultValue: "Pingvin Share invite",
|
||||||
},
|
},
|
||||||
inviteMessage: {
|
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",
|
type: "text",
|
||||||
value:
|
defaultValue:
|
||||||
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
|
"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: {
|
smtp: {
|
||||||
enabled: {
|
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",
|
type: "boolean",
|
||||||
value: "false",
|
defaultValue: "false",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
description: "Host of the SMTP server",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
description: "Port of the SMTP server",
|
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "0",
|
defaultValue: "0",
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
description: "Email address which the emails get sent from",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
username: {
|
username: {
|
||||||
description: "Username of the SMTP server",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
description: "Password of the SMTP server",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
defaultValue: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -162,7 +130,15 @@ type ConfigVariables = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url:
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
"file:../data/pingvin-share.db?connection_limit=1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function seedConfigVariables() {
|
async function seedConfigVariables() {
|
||||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private authTotpService: AuthTotpService,
|
private authTotpService: AuthTotpService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
async signUp(
|
async signUp(
|
||||||
@Body() dto: AuthRegisterDTO,
|
@Body() dto: AuthRegisterDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("share.allowRegistration"))
|
if (!this.config.get("share.allowRegistration"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
@@ -50,7 +50,7 @@ export class AuthController {
|
|||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -61,7 +61,7 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signIn(
|
async signIn(
|
||||||
@Body() dto: AuthSignInDTO,
|
@Body() dto: AuthSignInDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.signIn(dto);
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export class AuthController {
|
|||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +81,14 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async signInTotp(
|
async signInTotp(
|
||||||
@Body() dto: AuthSignInTotpDTO,
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.authTotpService.signInTotp(dto);
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken
|
result.accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
@@ -113,12 +113,12 @@ export class AuthController {
|
|||||||
async updatePassword(
|
async updatePassword(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() dto: UpdatePasswordDTO
|
@Body() dto: UpdatePasswordDTO,
|
||||||
) {
|
) {
|
||||||
const result = await this.authService.updatePassword(
|
const result = await this.authService.updatePassword(
|
||||||
user,
|
user,
|
||||||
dto.oldPassword,
|
dto.oldPassword,
|
||||||
dto.password
|
dto.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
response = this.addTokensToResponse(response, result.refreshToken);
|
response = this.addTokensToResponse(response, result.refreshToken);
|
||||||
@@ -129,12 +129,12 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
request.cookies.refresh_token
|
request.cookies.refresh_token,
|
||||||
);
|
);
|
||||||
response = this.addTokensToResponse(response, undefined, accessToken);
|
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||||
return new TokenDTO().from({ accessToken });
|
return new TokenDTO().from({ accessToken });
|
||||||
@@ -143,7 +143,7 @@ export class AuthController {
|
|||||||
@Post("signOut")
|
@Post("signOut")
|
||||||
async signOut(
|
async signOut(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
await this.authService.signOut(request.cookies.access_token);
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
@@ -176,7 +176,7 @@ export class AuthController {
|
|||||||
private addTokensToResponse(
|
private addTokensToResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
refreshToken?: string,
|
refreshToken?: string,
|
||||||
accessToken?: string
|
accessToken?: string,
|
||||||
) {
|
) {
|
||||||
if (accessToken)
|
if (accessToken)
|
||||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { User } from "@prisma/client";
|
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 argon from "argon2";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
@@ -21,7 +21,7 @@ export class AuthService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private emailService: EmailService
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
@@ -39,7 +39,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id
|
user.id,
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export class AuthService {
|
|||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`
|
`A user with this ${duplicatedField} already exists`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id
|
user.id,
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ export class AuthService {
|
|||||||
|
|
||||||
return this.createAccessToken(
|
return this.createAccessToken(
|
||||||
refreshTokenMetaData.user,
|
refreshTokenMetaData.user,
|
||||||
refreshTokenMetaData.id
|
refreshTokenMetaData.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
|
|||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import { authenticator, totp } from "otplib";
|
import { authenticator, totp } from "otplib";
|
||||||
import * as qrcode from "qrcode-svg";
|
import * as qrcode from "qrcode-svg";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
|||||||
export class AuthTotpService {
|
export class AuthTotpService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private authService: AuthService
|
private authService: AuthService,
|
||||||
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||||
@@ -42,7 +44,7 @@ export class AuthTotpService {
|
|||||||
throw new UnauthorizedException("Invalid login token");
|
throw new UnauthorizedException("Invalid login token");
|
||||||
|
|
||||||
if (token.expiresAt < new Date())
|
if (token.expiresAt < new Date())
|
||||||
throw new UnauthorizedException("Login token expired");
|
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||||
|
|
||||||
// Check the TOTP code
|
// Check the TOTP code
|
||||||
const { totpSecret } = await this.prisma.user.findUnique({
|
const { totpSecret } = await this.prisma.user.findUnique({
|
||||||
@@ -70,7 +72,7 @@ export class AuthTotpService {
|
|||||||
await this.authService.createRefreshToken(user.id);
|
await this.authService.createRefreshToken(user.id);
|
||||||
const accessToken = await this.authService.createAccessToken(
|
const accessToken = await this.authService.createAccessToken(
|
||||||
user,
|
user,
|
||||||
refreshTokenId
|
refreshTokenId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
@@ -95,8 +97,8 @@ export class AuthTotpService {
|
|||||||
|
|
||||||
const otpURL = totp.keyuri(
|
const otpURL = totp.keyuri(
|
||||||
user.username || user.email,
|
user.username || user.email,
|
||||||
"pingvin-share",
|
this.config.get("general.appName"),
|
||||||
secret
|
secret,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
|
|||||||
const request = ctx.switchToHttp().getRequest();
|
const request = ctx.switchToHttp().getRequest();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {
|
||||||
config.get("internal.jwtSecret");
|
config.get("internal.jwtSecret");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: JwtStrategy.extractJWT,
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import * as NodeClam from "clamscan";
|
import * as NodeClam from "clamscan";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
|
||||||
|
|
||||||
const clamscanConfig = {
|
const clamscanConfig = {
|
||||||
clamdscan: {
|
clamdscan: {
|
||||||
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
host: CLAMAV_HOST,
|
||||||
port: 3310,
|
port: CLAMAV_PORT,
|
||||||
localFallback: false,
|
localFallback: false,
|
||||||
},
|
},
|
||||||
preference: "clamdscan",
|
preference: "clamdscan",
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClamScanService {
|
export class ClamScanService {
|
||||||
|
private readonly logger = new Logger(ClamScanService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||||
.init(clamscanConfig)
|
.init(clamscanConfig)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log("ClamAV is active");
|
this.logger.log("ClamAV is active");
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.log("ClamAV is not active");
|
this.logger.log("ClamAV is not active");
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,14 +41,14 @@ export class ClamScanService {
|
|||||||
const infectedFiles = [];
|
const infectedFiles = [];
|
||||||
|
|
||||||
const files = fs
|
const files = fs
|
||||||
.readdirSync(`./data/uploads/shares/${shareId}`)
|
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
|
||||||
.filter((file) => file != "archive.zip");
|
.filter((file) => file != "archive.zip");
|
||||||
|
|
||||||
for (const fileId of files) {
|
for (const fileId of files) {
|
||||||
const { isInfected } = await clamScan
|
const { isInfected } = await clamScan
|
||||||
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.log("ClamAV is not active");
|
this.logger.log("ClamAV is not active");
|
||||||
return { isInfected: false };
|
return { isInfected: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,8 +80,8 @@ export class ClamScanService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
this.logger.warn(
|
||||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
FileTypeValidator,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseFilePipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { SkipThrottle } from "@nestjs/throttler";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
@@ -16,12 +21,14 @@ import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
|||||||
import { ConfigDTO } from "./dto/config.dto";
|
import { ConfigDTO } from "./dto/config.dto";
|
||||||
import { TestEmailDTO } from "./dto/testEmail.dto";
|
import { TestEmailDTO } from "./dto/testEmail.dto";
|
||||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||||
|
import { LogoService } from "./logo.service";
|
||||||
|
|
||||||
@Controller("configs")
|
@Controller("configs")
|
||||||
export class ConfigController {
|
export class ConfigController {
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private emailService: EmailService
|
private logoService: LogoService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -34,7 +41,7 @@ export class ConfigController {
|
|||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async getByCategory(@Param("category") category: string) {
|
async getByCategory(@Param("category") category: string) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.getByCategory(category)
|
await this.configService.getByCategory(category),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ export class ConfigController {
|
|||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
return new AdminConfigDTO().fromList(
|
return new AdminConfigDTO().fromList(
|
||||||
await this.configService.updateMany(data)
|
await this.configService.updateMany(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,4 +58,18 @@ export class ConfigController {
|
|||||||
async testEmail(@Body() { email }: TestEmailDTO) {
|
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||||
await this.emailService.sendTestMail(email);
|
await this.emailService.sendTestMail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("admin/logo")
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async uploadLogo(
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return await this.logoService.create(file.buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EmailModule } from "src/email/email.module";
|
|||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ConfigController } from "./config.controller";
|
import { ConfigController } from "./config.controller";
|
||||||
import { ConfigService } from "./config.service";
|
import { ConfigService } from "./config.service";
|
||||||
|
import { LogoService } from "./logo.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -16,6 +17,7 @@ import { ConfigService } from "./config.service";
|
|||||||
inject: [PrismaService],
|
inject: [PrismaService],
|
||||||
},
|
},
|
||||||
ConfigService,
|
ConfigService,
|
||||||
|
LogoService,
|
||||||
],
|
],
|
||||||
controllers: [ConfigController],
|
controllers: [ConfigController],
|
||||||
exports: [ConfigService],
|
exports: [ConfigService],
|
||||||
|
|||||||
@@ -11,20 +11,22 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(key: `${string}.${string}`): any {
|
get(key: `${string}.${string}`): any {
|
||||||
const configVariable = this.configVariables.filter(
|
const configVariable = this.configVariables.filter(
|
||||||
(variable) => `${variable.category}.${variable.name}` == key
|
(variable) => `${variable.category}.${variable.name}` == key,
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||||
|
|
||||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
const value = configVariable.value ?? configVariable.defaultValue;
|
||||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
|
||||||
|
if (configVariable.type == "number") return parseInt(value);
|
||||||
|
if (configVariable.type == "boolean") return value == "true";
|
||||||
if (configVariable.type == "string" || configVariable.type == "text")
|
if (configVariable.type == "string" || configVariable.type == "text")
|
||||||
return configVariable.value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByCategory(category: string) {
|
async getByCategory(category: string) {
|
||||||
@@ -35,8 +37,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
return configVariables.map((variable) => {
|
return configVariables.map((variable) => {
|
||||||
return {
|
return {
|
||||||
key: `${variable.category}.${variable.name}`,
|
|
||||||
...variable,
|
...variable,
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
value: variable.value ?? variable.defaultValue,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -48,8 +51,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
return configVariables.map((variable) => {
|
return configVariables.map((variable) => {
|
||||||
return {
|
return {
|
||||||
key: `${variable.category}.${variable.name}`,
|
|
||||||
...variable,
|
...variable,
|
||||||
|
key: `${variable.category}.${variable.name}`,
|
||||||
|
value: variable.value ?? variable.defaultValue,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,13 +81,15 @@ export class ConfigService {
|
|||||||
if (!configVariable || configVariable.locked)
|
if (!configVariable || configVariable.locked)
|
||||||
throw new NotFoundException("Config variable not found");
|
throw new NotFoundException("Config variable not found");
|
||||||
|
|
||||||
if (
|
if (value === "") {
|
||||||
|
value = null;
|
||||||
|
} else if (
|
||||||
typeof value != configVariable.type &&
|
typeof value != configVariable.type &&
|
||||||
typeof value == "string" &&
|
typeof value == "string" &&
|
||||||
configVariable.type != "text"
|
configVariable.type != "text"
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Config variable must be of type ${configVariable.type}`
|
`Config variable must be of type ${configVariable.type}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +100,7 @@ export class ConfigService {
|
|||||||
name: key.split(".")[1],
|
name: key.split(".")[1],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: { value: value.toString() },
|
data: { value: value === null ? null : value.toString() },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.configVariables = await this.prisma.config.findMany();
|
this.configVariables = await this.prisma.config.findMany();
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
secret: boolean;
|
secret: boolean;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
updatedAt: Date;
|
defaultValue: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
description: string;
|
updatedAt: Date;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class ConfigDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<ConfigDTO>[]) {
|
fromList(partial: Partial<ConfigDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
|
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { IsNotEmpty, IsString, ValidateIf } from "class-validator";
|
import { IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
class UpdateConfigDTO {
|
class UpdateConfigDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
key: string;
|
key: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ValidateIf((dto) => dto.value !== "")
|
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
backend/src/config/logo.service.ts
Normal file
32
backend/src/config/logo.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as sharp from "sharp";
|
||||||
|
|
||||||
|
const IMAGES_PATH = "../frontend/public/img";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
async create(file: Buffer) {
|
||||||
|
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||||
|
this.createFavicon(file);
|
||||||
|
this.createPWAIcons(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFavicon(file: Buffer) {
|
||||||
|
const resized = await sharp(file).resize(16).toBuffer();
|
||||||
|
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPWAIcons(file: Buffer) {
|
||||||
|
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
const resized = await sharp(file).resize(size).toBuffer();
|
||||||
|
fs.promises.writeFile(
|
||||||
|
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||||
|
resized,
|
||||||
|
"binary",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/constants.ts
Normal file
9
backend/src/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
|
||||||
|
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`;
|
||||||
|
export const DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
"file:../data/pingvin-share.db?connection_limit=1";
|
||||||
|
export const CLAMAV_HOST =
|
||||||
|
process.env.CLAMAV_HOST ||
|
||||||
|
(process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
|
||||||
|
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import * as moment from "moment";
|
||||||
import * as nodemailer from "nodemailer";
|
import * as nodemailer from "nodemailer";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
|
||||||
getTransporter() {
|
getTransporter() {
|
||||||
if (!this.config.get("smtp.enabled"))
|
if (!this.config.get("smtp.enabled"))
|
||||||
@@ -22,92 +28,106 @@ 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,
|
recipientEmail: string,
|
||||||
shareId: string,
|
shareId: string,
|
||||||
creator?: User
|
creator?: User,
|
||||||
|
description?: string,
|
||||||
|
expiration?: Date,
|
||||||
) {
|
) {
|
||||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
recipientEmail,
|
||||||
"smtp.email"
|
this.config.get("email.shareRecipientsSubject"),
|
||||||
)}>`,
|
this.config
|
||||||
to: recipientEmail,
|
|
||||||
subject: this.config.get("email.shareRecipientsSubject"),
|
|
||||||
text: this.config
|
|
||||||
.get("email.shareRecipientsMessage")
|
.get("email.shareRecipientsMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl)
|
||||||
});
|
.replaceAll("{desc}", description ?? "No description")
|
||||||
|
.replaceAll(
|
||||||
|
"{expires}",
|
||||||
|
moment(expiration).unix() != 0
|
||||||
|
? moment(expiration).fromNow()
|
||||||
|
: "in: never",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
||||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
recipientEmail,
|
||||||
"smtp.email"
|
this.config.get("email.reverseShareSubject"),
|
||||||
)}>`,
|
this.config
|
||||||
to: recipientEmail,
|
|
||||||
subject: this.config.get("email.reverseShareSubject"),
|
|
||||||
text: this.config
|
|
||||||
.get("email.reverseShareMessage")
|
.get("email.reverseShareMessage")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||||
const resetPasswordUrl = `${this.config.get(
|
const resetPasswordUrl = `${this.config.get(
|
||||||
"general.appUrl"
|
"general.appUrl",
|
||||||
)}/auth/resetPassword/${token}`;
|
)}/auth/resetPassword/${token}`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
recipientEmail,
|
||||||
"smtp.email"
|
this.config.get("email.resetPasswordSubject"),
|
||||||
)}>`,
|
this.config
|
||||||
to: recipientEmail,
|
|
||||||
subject: this.config.get("email.resetPasswordSubject"),
|
|
||||||
text: this.config
|
|
||||||
.get("email.resetPasswordMessage")
|
.get("email.resetPasswordMessage")
|
||||||
|
.replaceAll("\\n", "\n")
|
||||||
.replaceAll("{url}", resetPasswordUrl),
|
.replaceAll("{url}", resetPasswordUrl),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteEmail(recipientEmail: string, password: string) {
|
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||||
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||||
|
|
||||||
await this.getTransporter().sendMail({
|
await this.sendMail(
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
recipientEmail,
|
||||||
"smtp.email"
|
this.config.get("email.inviteSubject"),
|
||||||
)}>`,
|
this.config
|
||||||
to: recipientEmail,
|
|
||||||
subject: this.config.get("email.inviteSubject"),
|
|
||||||
text: this.config
|
|
||||||
.get("email.inviteMessage")
|
.get("email.inviteMessage")
|
||||||
.replaceAll("{url}", loginUrl)
|
.replaceAll("{url}", loginUrl)
|
||||||
.replaceAll("{password}", password),
|
.replaceAll("{password}", password),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendTestMail(recipientEmail: string) {
|
||||||
try {
|
await this.getTransporter()
|
||||||
await this.getTransporter().sendMail({
|
.sendMail({
|
||||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||||
"smtp.email"
|
"smtp.email",
|
||||||
)}>`,
|
)}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Test email",
|
subject: "Test email",
|
||||||
text: "This is a 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,17 +28,18 @@ export class FileController {
|
|||||||
@Query() query: any,
|
@Query() query: any,
|
||||||
|
|
||||||
@Body() body: string,
|
@Body() body: string,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string,
|
||||||
) {
|
) {
|
||||||
const { id, name, chunkIndex, totalChunks } = query;
|
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(
|
return await this.fileService.create(
|
||||||
data,
|
data,
|
||||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||||
{ id, name },
|
{ id, name },
|
||||||
shareId
|
shareId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +47,12 @@ export class FileController {
|
|||||||
@UseGuards(FileSecurityGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string,
|
||||||
) {
|
) {
|
||||||
const zip = this.fileService.getZip(shareId);
|
const zip = this.fileService.getZip(shareId);
|
||||||
res.set({
|
res.set({
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(zip);
|
return new StreamableFile(zip);
|
||||||
@@ -63,7 +64,7 @@ export class FileController {
|
|||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string,
|
@Param("fileId") fileId: string,
|
||||||
@Query("download") download = "true"
|
@Query("download") download = "true",
|
||||||
) {
|
) {
|
||||||
const file = await this.fileService.get(shareId, fileId);
|
const file = await this.fileService.get(shareId, fileId);
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,21 @@ import * as fs from "fs";
|
|||||||
import * as mime from "mime-types";
|
import * as mime from "mime-types";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { SHARE_DIRECTORY } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileService {
|
export class FileService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
data: string,
|
data: string,
|
||||||
chunk: { index: number; total: number },
|
chunk: { index: number; total: number },
|
||||||
file: { id?: string; name: string },
|
file: { id?: string; name: string },
|
||||||
shareId: string
|
shareId: string,
|
||||||
) {
|
) {
|
||||||
if (!file.id) file.id = crypto.randomUUID();
|
if (!file.id) file.id = crypto.randomUUID();
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export class FileService {
|
|||||||
let diskFileSize: number;
|
let diskFileSize: number;
|
||||||
try {
|
try {
|
||||||
diskFileSize = fs.statSync(
|
diskFileSize = fs.statSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
).size;
|
).size;
|
||||||
} catch {
|
} catch {
|
||||||
diskFileSize = 0;
|
diskFileSize = 0;
|
||||||
@@ -61,7 +62,7 @@ export class FileService {
|
|||||||
// Check if share size limit is exceeded
|
// Check if share size limit is exceeded
|
||||||
const fileSizeSum = share.files.reduce(
|
const fileSizeSum = share.files.reduce(
|
||||||
(n, { size }) => n + parseInt(size),
|
(n, { size }) => n + parseInt(size),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||||
@@ -73,23 +74,23 @@ export class FileService {
|
|||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
"Max share size exceeded",
|
"Max share size exceeded",
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
buffer
|
buffer,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLastChunk = chunk.index == chunk.total - 1;
|
const isLastChunk = chunk.index == chunk.total - 1;
|
||||||
if (isLastChunk) {
|
if (isLastChunk) {
|
||||||
fs.renameSync(
|
fs.renameSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||||
`./data/uploads/shares/${shareId}/${file.id}`
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||||
);
|
);
|
||||||
const fileSize = fs.statSync(
|
const fileSize = fs.statSync(
|
||||||
`./data/uploads/shares/${shareId}/${file.id}`
|
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||||
).size;
|
).size;
|
||||||
await this.prisma.file.create({
|
await this.prisma.file.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -111,9 +112,7 @@ export class FileService {
|
|||||||
|
|
||||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||||
|
|
||||||
const file = fs.createReadStream(
|
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||||
`./data/uploads/shares/${shareId}/${fileId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metaData: {
|
metaData: {
|
||||||
@@ -126,13 +125,13 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllFiles(shareId: string) {
|
async deleteAllFiles(shareId: string) {
|
||||||
await fs.promises.rm(`./data/uploads/shares/${shareId}`, {
|
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getZip(shareId: string) {
|
getZip(shareId: string) {
|
||||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
|
|||||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
constructor(
|
constructor(
|
||||||
private _shareService: ShareService,
|
private _shareService: ShareService,
|
||||||
private _prisma: PrismaService
|
private _prisma: PrismaService,
|
||||||
) {
|
) {
|
||||||
super(_shareService, _prisma);
|
super(_shareService, _prisma);
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
|||||||
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId",
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
@@ -52,7 +52,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
|||||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Maximum views exceeded",
|
"Maximum views exceeded",
|
||||||
"share_max_views_exceeded"
|
"share_max_views_exceeded",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
|
import { SHARE_DIRECTORY } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobsService {
|
export class JobsService {
|
||||||
|
private readonly logger = new Logger(JobsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private fileService: FileService
|
private fileService: FileService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
@@ -34,8 +37,9 @@ export class JobsService {
|
|||||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expiredShares.length > 0)
|
if (expiredShares.length > 0) {
|
||||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
@@ -50,10 +54,11 @@ export class JobsService {
|
|||||||
await this.reverseShareService.remove(expiredReverseShare.id);
|
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expiredReverseShares.length > 0)
|
if (expiredReverseShares.length > 0) {
|
||||||
console.log(
|
this.logger.log(
|
||||||
`job: deleted ${expiredReverseShares.length} expired reverse shares`
|
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 0 * * *")
|
@Cron("0 0 * * *")
|
||||||
@@ -61,31 +66,31 @@ export class JobsService {
|
|||||||
let filesDeleted = 0;
|
let filesDeleted = 0;
|
||||||
|
|
||||||
const shareDirectories = fs
|
const shareDirectories = fs
|
||||||
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
|
||||||
.filter((dirent) => dirent.isDirectory())
|
.filter((dirent) => dirent.isDirectory())
|
||||||
.map((dirent) => dirent.name);
|
.map((dirent) => dirent.name);
|
||||||
|
|
||||||
for (const shareDirectory of shareDirectories) {
|
for (const shareDirectory of shareDirectories) {
|
||||||
const temporaryFiles = fs
|
const temporaryFiles = fs
|
||||||
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
|
||||||
.filter((file) => file.endsWith(".tmp-chunk"));
|
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||||
|
|
||||||
for (const file of temporaryFiles) {
|
for (const file of temporaryFiles) {
|
||||||
const stats = fs.statSync(
|
const stats = fs.statSync(
|
||||||
`./data/uploads/shares/${shareDirectory}/${file}`
|
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||||
);
|
);
|
||||||
const isOlderThanOneDay = moment(stats.mtime)
|
const isOlderThanOneDay = moment(stats.mtime)
|
||||||
.add(1, "day")
|
.add(1, "day")
|
||||||
.isBefore(moment());
|
.isBefore(moment());
|
||||||
|
|
||||||
if (isOlderThanOneDay) {
|
if (isOlderThanOneDay) {
|
||||||
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
|
||||||
filesDeleted++;
|
filesDeleted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`job: deleted ${filesDeleted} temporary files`);
|
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
@@ -107,7 +112,8 @@ export class JobsService {
|
|||||||
const deletedTokensCount =
|
const deletedTokensCount =
|
||||||
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||||
|
|
||||||
if (deletedTokensCount > 0)
|
if (deletedTokensCount > 0) {
|
||||||
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
|
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as bodyParser from "body-parser";
|
|||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
import { DATA_DIRECTORY } from "./constants";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
@@ -16,7 +17,9 @@ async function bootstrap() {
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
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");
|
app.setGlobalPrefix("api");
|
||||||
|
|
||||||
@@ -30,6 +33,6 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/swagger", app, document);
|
SwaggerModule.setup("api/swagger", app, document);
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.listen(8080);
|
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { DATABASE_URL } from "../constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient {
|
export class PrismaService extends PrismaClient {
|
||||||
@@ -7,7 +8,7 @@ export class PrismaService extends PrismaClient {
|
|||||||
super({
|
super({
|
||||||
datasources: {
|
datasources: {
|
||||||
db: {
|
db: {
|
||||||
url: "file:../data/pingvin-share.db?connection_limit=1",
|
url: DATABASE_URL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export class ReverseShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
shareExpiration: Date;
|
shareExpiration: Date;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
token: string;
|
||||||
|
|
||||||
from(partial: Partial<ReverseShareDTO>) {
|
from(partial: Partial<ReverseShareDTO>) {
|
||||||
return plainToClass(ReverseShareDTO, partial, {
|
return plainToClass(ReverseShareDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
|||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ReverseShareTokenWithShares, part, {
|
plainToClass(ReverseShareTokenWithShares, part, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
|
|||||||
export class ReverseShareController {
|
export class ReverseShareController {
|
||||||
constructor(
|
constructor(
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private config: ConfigService
|
private config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -44,7 +44,7 @@ export class ReverseShareController {
|
|||||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||||
|
|
||||||
return new ReverseShareDTO().from(
|
return new ReverseShareDTO().from(
|
||||||
await this.reverseShareService.getByToken(reverseShareToken)
|
await this.reverseShareService.getByToken(reverseShareToken),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export class ReverseShareController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getAllByUser(@GetUser() user: User) {
|
async getAllByUser(@GetUser() user: User) {
|
||||||
return new ReverseShareTokenWithShares().fromList(
|
return new ReverseShareTokenWithShares().fromList(
|
||||||
await this.reverseShareService.getAllByUser(user.id)
|
await this.reverseShareService.getAllByUser(user.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class ReverseShareService {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private fileService: FileService
|
private fileService: FileService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||||
@@ -19,8 +19,8 @@ export class ReverseShareService {
|
|||||||
.add(
|
.add(
|
||||||
data.shareExpiration.split("-")[0],
|
data.shareExpiration.split("-")[0],
|
||||||
data.shareExpiration.split(
|
data.shareExpiration.split(
|
||||||
"-"
|
"-",
|
||||||
)[1] as moment.unitOfTime.DurationConstructor
|
)[1] as moment.unitOfTime.DurationConstructor,
|
||||||
)
|
)
|
||||||
.toDate();
|
.toDate();
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export class ReverseShareService {
|
|||||||
|
|
||||||
if (globalMaxShareSize < data.maxShareSize)
|
if (globalMaxShareSize < data.maxShareSize)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Max share size can't be greater than ${globalMaxShareSize} bytes.`
|
`Max share size can't be greater than ${globalMaxShareSize} bytes.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const reverseShare = await this.prisma.reverseShare.create({
|
const reverseShare = await this.prisma.reverseShare.create({
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Expose, plainToClass } from "class-transformer";
|
import { Expose, plainToClass, Type } from "class-transformer";
|
||||||
import { ShareDTO } from "./share.dto";
|
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()
|
@Expose()
|
||||||
views: number;
|
views: number;
|
||||||
|
|
||||||
@@ -11,13 +17,17 @@ export class MyShareDTO extends ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
|
||||||
|
files: Omit<FileDTO, "share" | "from">[];
|
||||||
|
|
||||||
from(partial: Partial<MyShareDTO>) {
|
from(partial: Partial<MyShareDTO>) {
|
||||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
fromList(partial: Partial<MyShareDTO>[]) {
|
fromList(partial: Partial<MyShareDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
|
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class ShareDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<ShareDTO>[]) {
|
fromList(partial: Partial<ShareDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(ShareDTO, part, { excludeExtraneousValues: true })
|
plainToClass(ShareDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
|||||||
export class CreateShareGuard extends JwtGuard {
|
export class CreateShareGuard extends JwtGuard {
|
||||||
constructor(
|
constructor(
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private reverseShareService: ReverseShareService
|
private reverseShareService: ReverseShareService,
|
||||||
) {
|
) {
|
||||||
super(configService);
|
super(configService);
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export class CreateShareGuard extends JwtGuard {
|
|||||||
if (!reverseShareTokenId) return false;
|
if (!reverseShareTokenId) return false;
|
||||||
|
|
||||||
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
||||||
reverseShareTokenId
|
reverseShareTokenId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return isReverseShareTokenValid;
|
return isReverseShareTokenValid;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class ShareOwnerGuard implements CanActivate {
|
|||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId",
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
|
|||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
@@ -22,7 +22,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId",
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
@@ -44,13 +44,13 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
if (share.security?.password && !shareToken)
|
if (share.security?.password && !shareToken)
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"This share is password protected",
|
"This share is password protected",
|
||||||
"share_password_required"
|
"share_password_required",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Share token required",
|
"Share token required",
|
||||||
"share_token_required"
|
"share_token_required",
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
|
|||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId",
|
||||||
)
|
)
|
||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class ShareController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getMyShares(@GetUser() user: User) {
|
async getMyShares(@GetUser() user: User) {
|
||||||
return new MyShareDTO().fromList(
|
return new MyShareDTO().fromList(
|
||||||
await this.shareService.getSharesByUser(user.id)
|
await this.shareService.getSharesByUser(user.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +54,11 @@ export class ShareController {
|
|||||||
async create(
|
async create(
|
||||||
@Body() body: CreateShareDTO,
|
@Body() body: CreateShareDTO,
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@GetUser() user: User
|
@GetUser() user: User,
|
||||||
) {
|
) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new ShareDTO().from(
|
return new ShareDTO().from(
|
||||||
await this.shareService.create(body, user, reverse_share_token)
|
await this.shareService.create(body, user, reverse_share_token),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export class ShareController {
|
|||||||
async complete(@Param("id") id: string, @Req() request: Request) {
|
async complete(@Param("id") id: string, @Req() request: Request) {
|
||||||
const { reverse_share_token } = request.cookies;
|
const { reverse_share_token } = request.cookies;
|
||||||
return new ShareDTO().from(
|
return new ShareDTO().from(
|
||||||
await this.shareService.complete(id, reverse_share_token)
|
await this.shareService.complete(id, reverse_share_token),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export class ShareController {
|
|||||||
async getShareToken(
|
async getShareToken(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
@Body() body: SharePasswordDto
|
@Body() body: SharePasswordDto,
|
||||||
) {
|
) {
|
||||||
const token = await this.shareService.getShareToken(id, body.password);
|
const token = await this.shareService.getShareToken(id, body.password);
|
||||||
response.cookie(`share_${id}_token`, token, {
|
response.cookie(`share_${id}_token`, token, {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
|
|||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||||
|
import { SHARE_DIRECTORY } from "../constants";
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,7 +28,7 @@ export class ShareService {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private reverseShareService: ReverseShareService,
|
private reverseShareService: ReverseShareService,
|
||||||
private clamScanService: ClamScanService
|
private clamScanService: ClamScanService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
async create(share: CreateShareDTO, user?: User, reverseShareToken?: string) {
|
||||||
@@ -45,7 +46,7 @@ export class ShareService {
|
|||||||
|
|
||||||
// If share is created by a reverse share token override the expiration date
|
// If share is created by a reverse share token override the expiration date
|
||||||
const reverseShare = await this.reverseShareService.getByToken(
|
const reverseShare = await this.reverseShareService.getByToken(
|
||||||
reverseShareToken
|
reverseShareToken,
|
||||||
);
|
);
|
||||||
if (reverseShare) {
|
if (reverseShare) {
|
||||||
expirationDate = reverseShare.shareExpiration;
|
expirationDate = reverseShare.shareExpiration;
|
||||||
@@ -56,8 +57,8 @@ export class ShareService {
|
|||||||
.add(
|
.add(
|
||||||
share.expiration.split("-")[0],
|
share.expiration.split("-")[0],
|
||||||
share.expiration.split(
|
share.expiration.split(
|
||||||
"-"
|
"-",
|
||||||
)[1] as moment.unitOfTime.DurationConstructor
|
)[1] as moment.unitOfTime.DurationConstructor,
|
||||||
)
|
)
|
||||||
.toDate();
|
.toDate();
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +66,7 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,11 +100,11 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createZip(shareId: string) {
|
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 files = await this.prisma.file.findMany({ where: { shareId } });
|
||||||
const archive = archiver("zip", {
|
const archive = archiver("zip", {
|
||||||
zlib: { level: 9 },
|
zlib: { level: this.config.get("share.zipCompressionLevel") },
|
||||||
});
|
});
|
||||||
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
||||||
|
|
||||||
@@ -133,21 +134,23 @@ export class ShareService {
|
|||||||
|
|
||||||
if (share.files.length == 0)
|
if (share.files.length == 0)
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You need at least on file in your share to complete it."
|
"You need at least on file in your share to complete it.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Asynchronously create a zip of all files
|
// Asynchronously create a zip of all files
|
||||||
if (share.files.length > 1)
|
if (share.files.length > 1)
|
||||||
this.createZip(id).then(() =>
|
this.createZip(id).then(() =>
|
||||||
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
|
this.prisma.share.update({ where: { id }, data: { isZipReady: true } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send email for each recepient
|
// Send email for each recipient
|
||||||
for (const recepient of share.recipients) {
|
for (const recipient of share.recipients) {
|
||||||
await this.emailService.sendMailToShareRecepients(
|
await this.emailService.sendMailToShareRecipients(
|
||||||
recepient.email,
|
recipient.email,
|
||||||
share.id,
|
share.id,
|
||||||
share.creator
|
share.creator,
|
||||||
|
share.description,
|
||||||
|
share.expiration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +161,12 @@ export class ShareService {
|
|||||||
) {
|
) {
|
||||||
await this.emailService.sendMailToReverseShareCreator(
|
await this.emailService.sendMailToReverseShareCreator(
|
||||||
share.reverseShare.creator.email,
|
share.reverseShare.creator.email,
|
||||||
share.id
|
share.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any file is malicious with ClamAV
|
// Check if any file is malicious with ClamAV
|
||||||
this.clamScanService.checkAndRemove(share.id);
|
void this.clamScanService.checkAndRemove(share.id);
|
||||||
|
|
||||||
if (share.reverseShare) {
|
if (share.reverseShare) {
|
||||||
await this.prisma.reverseShare.update({
|
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 },
|
where: { id },
|
||||||
data: { uploadLocked: true },
|
data: { uploadLocked: true },
|
||||||
});
|
});
|
||||||
@@ -192,17 +195,15 @@ export class ShareService {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
expiration: "desc",
|
expiration: "desc",
|
||||||
},
|
},
|
||||||
include: { recipients: true },
|
include: { recipients: true, files: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharesWithEmailRecipients = shares.map((share) => {
|
return shares.map((share) => {
|
||||||
return {
|
return {
|
||||||
...share,
|
...share,
|
||||||
recipients: share.recipients.map((recipients) => recipients.email),
|
recipients: share.recipients.map((recipients) => recipients.email),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return sharesWithEmailRecipients;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<any> {
|
async get(id: string): Promise<any> {
|
||||||
@@ -222,7 +223,7 @@ export class ShareService {
|
|||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
return {
|
return {
|
||||||
...share,
|
...share,
|
||||||
hasPassword: share.security?.password ? true : false,
|
hasPassword: !!share.security?.password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,13 +279,13 @@ export class ShareService {
|
|||||||
share?.security?.password &&
|
share?.security?.password &&
|
||||||
!(await argon.verify(share.security.password, password))
|
!(await argon.verify(share.security.password, password))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException("Wrong password");
|
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Maximum views exceeded",
|
"Maximum views exceeded",
|
||||||
"share_max_views_exceeded"
|
"share_max_views_exceeded",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +305,7 @@ export class ShareService {
|
|||||||
{
|
{
|
||||||
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
|
||||||
secret: this.config.get("internal.jwtSecret"),
|
secret: this.config.get("internal.jwtSecret"),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { OmitType, PartialType } from "@nestjs/swagger";
|
|||||||
import { UserDTO } from "./user.dto";
|
import { UserDTO } from "./user.dto";
|
||||||
|
|
||||||
export class UpdateOwnUserDTO extends PartialType(
|
export class UpdateOwnUserDTO extends PartialType(
|
||||||
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
OmitType(UserDTO, ["isAdmin", "password"] as const),
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class UserDTO {
|
|||||||
|
|
||||||
fromList(partial: Partial<UserDTO>[]) {
|
fromList(partial: Partial<UserDTO>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class UserController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async updateCurrentUser(
|
async updateCurrentUser(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Body() data: UpdateOwnUserDTO
|
@Body() data: UpdateOwnUserDTO,
|
||||||
) {
|
) {
|
||||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export class UserController {
|
|||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async deleteCurrentUser(
|
async deleteCurrentUser(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Res({ passthrough: true }) response: Response
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
response.cookie("refresh_token", "", {
|
response.cookie("refresh_token", "", {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
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 argon from "argon2";
|
||||||
|
import * as crypto from "crypto";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||||
@@ -10,7 +11,7 @@ import { UpdateUserDto } from "./dto/updateUser.dto";
|
|||||||
export class UserSevice {
|
export class UserSevice {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private emailService: EmailService
|
private emailService: EmailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
@@ -28,7 +29,7 @@ export class UserSevice {
|
|||||||
if (!dto.password) {
|
if (!dto.password) {
|
||||||
const randomPassword = crypto.randomUUID();
|
const randomPassword = crypto.randomUUID();
|
||||||
hash = await argon.hash(randomPassword);
|
hash = await argon.hash(randomPassword);
|
||||||
this.emailService.sendInviteEmail(dto.email, randomPassword);
|
await this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||||
} else {
|
} else {
|
||||||
hash = await argon.hash(dto.password);
|
hash = await argon.hash(dto.password);
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ export class UserSevice {
|
|||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`
|
`A user with this ${duplicatedField} already exists`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,7 @@ export class UserSevice {
|
|||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
const duplicatedField: string = e.meta.target[0];
|
const duplicatedField: string = e.meta.target[0];
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A user with this ${duplicatedField} already exists`
|
`A user with this ${duplicatedField} already exists`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
crowdin.yml
Normal file
4
crowdin.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
files:
|
||||||
|
- source: /frontend/src/i18n/translations/en-US.ts
|
||||||
|
translation: /%original_path%/%locale%.ts
|
||||||
|
pull_request_title: "chore(translations): update translations via Crowdin"
|
||||||
95
docs/CONTRIBUTING.es.md
Normal file
95
docs/CONTRIBUTING.es.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
_Leer esto en otro idioma: [Inglés](/CONTRIBUTING.md), [Español](/docs/CONTRIBUTING.es.md), [Chino Simplificado](/docs/CONTRIBUTING.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contribuyendo
|
||||||
|
|
||||||
|
¡Nos ❤️ encantaría que contribuyas a Pingvin Share y nos ayudes a hacerlo mejor! Todas las contribuciones son bienvenidas, incluyendo problemas, sugerencias, _pull requests_ y más.
|
||||||
|
|
||||||
|
## Para comenzar
|
||||||
|
|
||||||
|
Si encontraste un error, tienes una sugerencia o algo más, simplemente crea un problema (issue) en GitHub y nos pondremos en contacto contigo 😊.
|
||||||
|
|
||||||
|
## Para hacer una Pull Request
|
||||||
|
|
||||||
|
Antes de enviar la pull request para su revisión, asegúrate de que:
|
||||||
|
|
||||||
|
- El nombre de la pull request sigue las [especificaciones de Commits Convencionales](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
`<tipo>[ámbito opcional]: <descripción>`
|
||||||
|
|
||||||
|
ejemplo:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): agregar protección con contraseña
|
||||||
|
```
|
||||||
|
|
||||||
|
Donde `tipo` puede ser:
|
||||||
|
|
||||||
|
- **feat** - es una nueva función
|
||||||
|
- **doc** - cambios solo en la documentación
|
||||||
|
- **fix** - una corrección de error
|
||||||
|
- **refactor** - cambios en el código que no solucionan un error ni agregan una función
|
||||||
|
|
||||||
|
- Tu pull requests tiene una descripción detallada.
|
||||||
|
|
||||||
|
- Ejecutaste `npm run format` para formatear el código.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>¿No sabes como crear una pull request? Aprende cómo crear una pull request</summary>
|
||||||
|
|
||||||
|
1. Crea un fork del repositorio haciendo clic en el botón `Fork` en el repositorio de Pingvin Share.
|
||||||
|
|
||||||
|
2. Clona tu fork en tu máquina con `git clone`.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/[your_username]/pingvin-share
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Trabajar - hacer commit - repetir
|
||||||
|
|
||||||
|
4. Haz un `push` de tus cambios a GitHub.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git push origin [nombre_de_tu_nueva_rama]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Envía tus cambios para su revisión. Si vas a tu repositorio en GitHub, verás un botón `Comparar y crear pull requests`. Haz clic en ese botón.
|
||||||
|
6. Inicia una Pull Request
|
||||||
|
7. Ahora envía la pull requests y haz clic en `Crear pull requests`
|
||||||
|
8. Espera a que alguien revise tu solicitud y apruebe o rechace tus cambios. Puedes ver los comentarios en la página de la solicitud en GitHub.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Instalación del proyecto
|
||||||
|
|
||||||
|
Pingvin Share consiste de un frontend y un backend.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
El backend está hecho con [Nest.js](https://nestjs.com) y usa Typescript.
|
||||||
|
|
||||||
|
#### Instalación
|
||||||
|
|
||||||
|
1. Abrimos la carpeta `backend`
|
||||||
|
2. Instalamos las dependencias con `npm install`
|
||||||
|
3. Haz un `push` del esquema de la base de datos a la base de datos ejecutando `npx prisma db push`
|
||||||
|
4. Rellena la base de datos ejecutando `npx prisma db seed`
|
||||||
|
5. Inicia el backend con `npm run dev`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
El frontend está hecho con [Next.js](https://nextjs.org) y usa Typescript.
|
||||||
|
|
||||||
|
#### Instalación
|
||||||
|
|
||||||
|
1. Primero inicia el backend
|
||||||
|
2. Abre la carpeta `frontend`
|
||||||
|
3. Instala las dependencias con `npm install`
|
||||||
|
4. Inicia el frontend con `npm run dev`
|
||||||
|
|
||||||
|
¡Ya está todo listo!
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Por el momento, solo tenemos pruebas para el backend. Para ejecutar estas pruebas, debes ejecutar el comando `npm run test:system` en la carpeta del backend.
|
||||||
97
docs/CONTRIBUTING.zh-cn.md
Normal file
97
docs/CONTRIBUTING.zh-cn.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
_选择合适的语言阅读: [西班牙语](/docs/CONTRIBUTING.es.md), [英语](/CONTRIBUTING.md), [简体中文](/docs/CONTRIBUTING.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 提交贡献
|
||||||
|
|
||||||
|
我们非常感谢你 ❤️ 为 Pingvin Share 提交贡献使其变得更棒! 欢迎任何形式的贡献,包括 issues, 建议, PRs 和其他形式
|
||||||
|
|
||||||
|
## 小小的开始
|
||||||
|
|
||||||
|
你找到了一个 bug,有新特性建议或者其他提议,请在 GitHub 建立一个 issue 以便我和你联络 😊
|
||||||
|
|
||||||
|
## 提交一个 Pull Request
|
||||||
|
|
||||||
|
在你提交 PR 前请确保
|
||||||
|
|
||||||
|
- PR 的名字遵守 [Conventional Commits specification](https://www.conventionalcommits.org):
|
||||||
|
|
||||||
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(share): add password protection
|
||||||
|
```
|
||||||
|
|
||||||
|
`TYPE` 可以是:
|
||||||
|
|
||||||
|
- **feat** - 这是一个新特性 feature
|
||||||
|
- **doc** - 仅仅改变了文档部分 documentation
|
||||||
|
- **fix** - 修复了一个 bug
|
||||||
|
- **refactor** - 更新了代码,但是并非出于增加新特性 feature 或修复 bug 的目的
|
||||||
|
|
||||||
|
- 请在 PR 中附详细的解释说明
|
||||||
|
- 使用 `npm run format` 格式化你的代码
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>不知道怎么发起一个 PR? 点开了解怎么发起一个 PR </summary>
|
||||||
|
|
||||||
|
1. 点击 Pingvin Share 仓库的 `Fork` 按钮,复制一份你的仓库
|
||||||
|
|
||||||
|
2. 通过 `git clone` 将你的仓库克隆到本地
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://github.com/[你的用户名]/pingvin-share
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 进行你的修改 - 提交 commit 你的修改 - 重复直到完成
|
||||||
|
|
||||||
|
4. 将你的修改提交到 GitHub
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git push origin [你的新分支的名字]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 提交你的代码以便代码审查
|
||||||
|
|
||||||
|
如果你进入你 fork 的 Github 仓库,你会看到一个 `Compare & pull request` 按钮,点击该按钮
|
||||||
|
|
||||||
|
6. 发起一个 PR
|
||||||
|
7. 点击 `Create pull request` 来提交你的 PR
|
||||||
|
8. 等待代码审查,通过或以某些原因拒绝
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 配置开发项目
|
||||||
|
|
||||||
|
Pingvin Share 包括前端和后端部分
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
后端使用 [Nest.js](https://nestjs.com) 建立,使用 Typescript
|
||||||
|
|
||||||
|
#### 搭建
|
||||||
|
|
||||||
|
1. 打开 `backend` 文件夹
|
||||||
|
2. 使用 `npm install` 安装依赖
|
||||||
|
3. 通过 `npx prisma db push` 配置数据库结构
|
||||||
|
4. 通过 `npx prisma db seed` 初始化数据库数据
|
||||||
|
5. 通过 `npm run dev` 启动后端
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
后端使用 [Next.js](https://nextjs.org) 建立,使用 Typescript
|
||||||
|
|
||||||
|
#### 搭建
|
||||||
|
|
||||||
|
1. 首先启动后端
|
||||||
|
2. 打开 `frontend` 文件夹
|
||||||
|
3. 通过 `npm install` 安装依赖
|
||||||
|
4. 通过 `npm run dev` 启动前端
|
||||||
|
|
||||||
|
开发项目配置完成
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
目前阶段我们只有后端的系统测试,在 `backend` 文件夹运行 `npm run test:system` 来执行系统测试
|
||||||
128
docs/README.es.md
Normal file
128
docs/README.es.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alternativa a WeTransfer.
|
||||||
|
|
||||||
|
## ✨ Características
|
||||||
|
|
||||||
|
- Compartir archivos utilizando un enlace
|
||||||
|
- Tamaño de archivo ilimitado (unicamente restringido por el espacio en disco)
|
||||||
|
- Establecer una fecha de caducidad para los recursos compartidos
|
||||||
|
- Uso compartido seguro con límites de visitantes y contraseñas
|
||||||
|
- Destinatarios de correo electrónico
|
||||||
|
- Integración con ClamAV para escaneos de seguridad
|
||||||
|
|
||||||
|
## 🐧 Conoce Pingvin Share
|
||||||
|
|
||||||
|
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [Reseña por DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
|
|
||||||
|
## ⌨️ Instalación
|
||||||
|
|
||||||
|
> Nota: Pingvin Share está en sus primeras etapas y puede contener errores.
|
||||||
|
|
||||||
|
### Instalación con Docker (recomendada)
|
||||||
|
|
||||||
|
1. Descarge el archivo `docker-compose.yml`
|
||||||
|
2. Ejecute `docker-compose up -d`
|
||||||
|
|
||||||
|
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Instalación autónoma
|
||||||
|
|
||||||
|
Herramientas requeridas:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) para ejecutar Pingvin Share en segundo plano
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Consultar la última versión
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Iniciar el backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# Iniciar el frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
El sitio web ahora está esperando conexiones en `http://localhost:3000`, ¡diviértase usando Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Integraciones
|
||||||
|
|
||||||
|
#### ClamAV (Unicamente con Docker)
|
||||||
|
|
||||||
|
ClamAV se utiliza para escanear los recursos compartidos en busca de archivos maliciosos y eliminarlos si los encuentra.
|
||||||
|
|
||||||
|
1. Añade el contenedor ClamAV al stack de Docker Compose (ver `docker-compose.yml`) e inicie el contenedor.
|
||||||
|
2. Docker esperará a que ClamAV se inicie antes de iniciar Pingvin Share. Esto puede tardar uno o dos minutos.
|
||||||
|
3. Los registros de Pingvin Share ahora deberían decir "ClamAV está activo".
|
||||||
|
|
||||||
|
Por favor, ten en cuenta que ClamAV necesita muchos [recursos](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
|
### Recursos adicionales
|
||||||
|
|
||||||
|
- [Instalación en Synology NAS (Inglés)](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
|
### Actualizar a una nueva versión
|
||||||
|
|
||||||
|
Dado que Pingvin Share se encuentra en una fase inicial, consulte las notas de la versión para conocer los cambios de última hora antes de actualizar.
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Instalación autónoma
|
||||||
|
|
||||||
|
1. Deten la aplicación en ejecución
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Repite los pasos de la [guía de instalación](#instalación-autonoma) excepto el paso de `git clone`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# Consultar la última versión
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# Iniciar el backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# Iniciar frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marca personalizada
|
||||||
|
|
||||||
|
Puedes cambiar el nombre y el logotipo de la aplicación visitando la página de configuración de administrador.
|
||||||
|
|
||||||
|
## 🖤 Contribuye
|
||||||
|
|
||||||
|
¡Eres bienvenido a contribuir a Pingvin Share! Sige la [guía de contribución](/CONTRIBUTING.md) para empezar.
|
||||||
126
docs/README.zh-cn.md
Normal file
126
docs/README.zh-cn.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pingvin Share 是一个可自建的文件分享平台,是 WeTransfer 的一个替代品
|
||||||
|
|
||||||
|
## ✨ 特性
|
||||||
|
|
||||||
|
- 通过可自定义后缀的链接分享文件
|
||||||
|
- 可自定义任意大小的文件上传限制 (受制于托管所在的硬盘大小)
|
||||||
|
- 对共享链接设置有效期限
|
||||||
|
- 对共享链接设置访问次数和访问密码
|
||||||
|
- 通过邮件自动发送共享链接
|
||||||
|
- 整合 ClamAV 进行反病毒检查
|
||||||
|
|
||||||
|
## 🐧 了解一下 Pingvin Share
|
||||||
|
|
||||||
|
- [示例网站](https://pingvin-share.dev.eliasschneider.com)
|
||||||
|
- [DB Tech 推荐视频](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||||
|
|
||||||
|
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||||
|
|
||||||
|
## ⌨️ 自建指南
|
||||||
|
|
||||||
|
> 注意:Pingvin Share 仍处于开发阶段并且可能存在 bugs
|
||||||
|
|
||||||
|
### Docker 部署 (推荐)
|
||||||
|
|
||||||
|
1. 下载 `docker-compose.yml`
|
||||||
|
2. 运行命令 `docker-compose up -d`
|
||||||
|
|
||||||
|
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Stand-alone 部署
|
||||||
|
|
||||||
|
必须的依赖:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
- [pm2](https://pm2.keymetrics.io/) 用于后台运行 Pingvin Share
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stonith404/pingvin-share
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# 获取最新的版本
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# 启动后端 backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||||
|
|
||||||
|
# 启动前端 frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||||
|
```
|
||||||
|
|
||||||
|
现在网站运行在 `http://localhost:3000`,尝试一下你本地的 Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### 整合组件
|
||||||
|
|
||||||
|
#### ClamAV (仅限 Docker 部署)
|
||||||
|
|
||||||
|
扫描上传文件中是否存在可疑文件,如果存在 ClamAV 会自动移除
|
||||||
|
|
||||||
|
1. 在 docker-compose 配置中添加 ClamAV 容器 (见 `docker-compose.yml` 注释部分) 并启动容器
|
||||||
|
2. Docker 会在启动 Pingvin Share 前启动 ClamAV,也许会花费 1-2 分钟
|
||||||
|
3. Pingvin Share 日志中应该有 "ClamAV is active"
|
||||||
|
|
||||||
|
请注意 ClamAV 会消耗很多 [系统资源(特别是内存)](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)
|
||||||
|
|
||||||
|
### 更多资源
|
||||||
|
|
||||||
|
- [群晖 NAS 配置](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|
||||||
|
### 升级
|
||||||
|
|
||||||
|
因为 Pingvin Share 仍处在开发阶段,在升级前请务必阅读 release notes 避免不可逆的改变
|
||||||
|
|
||||||
|
#### Docker 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stand-alone 升级
|
||||||
|
|
||||||
|
1. 停止正在运行的 app
|
||||||
|
```bash
|
||||||
|
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||||
|
```
|
||||||
|
2. 重复 [installation guide](#stand-alone-installation) 中的步骤,除了 `git clone` 这一步
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pingvin-share
|
||||||
|
|
||||||
|
# 获取最新的版本
|
||||||
|
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||||
|
|
||||||
|
# 启动后端 backend
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-backend
|
||||||
|
|
||||||
|
# 启动前端 frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
pm2 restart pingvin-share-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义品牌
|
||||||
|
|
||||||
|
你可以在管理员配置页面改变网站的名字和 logo
|
||||||
|
|
||||||
|
## 🖤 提交贡献
|
||||||
|
|
||||||
|
非常欢迎向 Pingvin Share 提交贡献! 请阅读 [contribution guide](/CONTRIBUTING.md) 来提交你的贡献
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": ["eslint-config-next", "eslint:recommended", "prettier"],
|
"extends": [
|
||||||
|
"next",
|
||||||
|
"eslint-config-next",
|
||||||
|
"eslint:recommended",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
"plugins": ["react"],
|
"plugins": ["react"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"quotes": ["warn", "double", { "allowTemplateLiterals": true }],
|
"quotes": ["warn", "double", { "allowTemplateLiterals": true }],
|
||||||
|
|||||||
1
frontend/.prettierignore
Normal file
1
frontend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/src/i18n/translations/*
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const withPWA = require("next-pwa")({
|
const withPWA = require("next-pwa")({
|
||||||
dest: "public",
|
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({
|
module.exports = withPWA({
|
||||||
output: "standalone", env: {
|
output: "standalone", env: {
|
||||||
VERSION: version,
|
VERSION: version,
|
||||||
},
|
},
|
||||||
|
serverRuntimeConfig: {
|
||||||
|
apiURL: process.env.API_URL ?? 'http://localhost:8080',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3556
frontend/package-lock.json
generated
3556
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.11.1",
|
"version": "0.18.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"src/**/*.ts*\""
|
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.11.0",
|
||||||
"@mantine/core": "^5.10.0",
|
"@mantine/core": "^6.0.17",
|
||||||
"@mantine/dropzone": "^5.10.0",
|
"@mantine/dropzone": "^6.0.17",
|
||||||
"@mantine/form": "^5.10.0",
|
"@mantine/form": "^6.0.17",
|
||||||
"@mantine/hooks": "^5.10.0",
|
"@mantine/hooks": "^6.0.17",
|
||||||
"@mantine/modals": "^5.10.0",
|
"@mantine/modals": "^6.0.17",
|
||||||
"@mantine/next": "^5.10.0",
|
"@mantine/next": "^6.0.17",
|
||||||
"@mantine/notifications": "^5.10.0",
|
"@mantine/notifications": "^6.0.17",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.4.0",
|
||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.2",
|
"jose": "^4.14.4",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.4.12",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
"next-http-proxy-middleware": "^1.2.5",
|
"next-http-proxy-middleware": "^1.2.5",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"p-limit": "^4.0.0",
|
"p-limit": "^4.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.7.1",
|
"react-icons": "^4.10.1",
|
||||||
"yup": "^0.32.11"
|
"react-intl": "^6.4.4",
|
||||||
|
"sharp": "^0.32.4",
|
||||||
|
"yup": "^1.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "20.4.5",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.2.17",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.2.7",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.4.0",
|
||||||
"eslint": "8.31.0",
|
"eslint": "8.46.0",
|
||||||
"eslint-config-next": "^13.1.2",
|
"eslint-config-next": "^13.4.12",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.9.0",
|
||||||
"prettier": "^2.8.2",
|
"prettier": "^3.0.0",
|
||||||
"tar": "^6.1.13",
|
"tar": "^6.1.15",
|
||||||
"typescript": "^4.9.4"
|
"typescript": "^5.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
36
frontend/src/components/account/LanguagePicker.tsx
Normal file
36
frontend/src/components/account/LanguagePicker.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Select } from "@mantine/core";
|
||||||
|
import { getCookie, setCookie } from "cookies-next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
|
import { LOCALES } from "../../i18n/locales";
|
||||||
|
|
||||||
|
const LanguagePicker = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState(
|
||||||
|
getCookie("language")?.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const languages = Object.values(LOCALES).map((locale) => ({
|
||||||
|
value: locale.code,
|
||||||
|
label: locale.name,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={selectedLanguage}
|
||||||
|
description={t("account.card.language.description")}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedLanguage(value ?? "en");
|
||||||
|
setCookie("language", value, {
|
||||||
|
sameSite: "lax",
|
||||||
|
expires: new Date(
|
||||||
|
new Date().setFullYear(new Date().getFullYear() + 1),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
location.reload();
|
||||||
|
}}
|
||||||
|
data={languages}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguagePicker;
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
import { useColorScheme } from "@mantine/hooks";
|
import { useColorScheme } from "@mantine/hooks";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb";
|
import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb";
|
||||||
import usePreferences from "../../hooks/usePreferences";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import userPreferences from "../../utils/userPreferences.util";
|
||||||
|
|
||||||
const ThemeSwitcher = () => {
|
const ThemeSwitcher = () => {
|
||||||
const preferences = usePreferences();
|
|
||||||
const [colorScheme, setColorScheme] = useState(
|
const [colorScheme, setColorScheme] = useState(
|
||||||
preferences.get("colorScheme")
|
userPreferences.get("colorScheme"),
|
||||||
);
|
);
|
||||||
const { toggleColorScheme } = useMantineColorScheme();
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
const systemColorScheme = useColorScheme();
|
const systemColorScheme = useColorScheme();
|
||||||
@@ -23,10 +23,10 @@ const ThemeSwitcher = () => {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={colorScheme}
|
value={colorScheme}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
preferences.set("colorScheme", value);
|
userPreferences.set("colorScheme", value);
|
||||||
setColorScheme(value);
|
setColorScheme(value);
|
||||||
toggleColorScheme(
|
toggleColorScheme(
|
||||||
value == "system" ? systemColorScheme : (value as ColorScheme)
|
value == "system" ? systemColorScheme : (value as ColorScheme),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
@@ -34,7 +34,9 @@ const ThemeSwitcher = () => {
|
|||||||
label: (
|
label: (
|
||||||
<Center>
|
<Center>
|
||||||
<TbMoon size={16} />
|
<TbMoon size={16} />
|
||||||
<Box ml={10}>Dark</Box>
|
<Box ml={10}>
|
||||||
|
<FormattedMessage id="account.theme.dark" />
|
||||||
|
</Box>
|
||||||
</Center>
|
</Center>
|
||||||
),
|
),
|
||||||
value: "dark",
|
value: "dark",
|
||||||
@@ -43,7 +45,9 @@ const ThemeSwitcher = () => {
|
|||||||
label: (
|
label: (
|
||||||
<Center>
|
<Center>
|
||||||
<TbSun size={16} />
|
<TbSun size={16} />
|
||||||
<Box ml={10}>Light</Box>
|
<Box ml={10}>
|
||||||
|
<FormattedMessage id="account.theme.light" />
|
||||||
|
</Box>
|
||||||
</Center>
|
</Center>
|
||||||
),
|
),
|
||||||
value: "light",
|
value: "light",
|
||||||
@@ -52,7 +56,9 @@ const ThemeSwitcher = () => {
|
|||||||
label: (
|
label: (
|
||||||
<Center>
|
<Center>
|
||||||
<TbDeviceLaptop size={16} />
|
<TbDeviceLaptop size={16} />
|
||||||
<Box ml={10}>System</Box>
|
<Box ml={10}>
|
||||||
|
<FormattedMessage id="account.theme.system" />
|
||||||
|
</Box>
|
||||||
</Center>
|
</Center>
|
||||||
),
|
),
|
||||||
value: "system",
|
value: "system",
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Col,
|
Group,
|
||||||
Grid,
|
|
||||||
Image,
|
Image,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../hooks/useTranslate.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
@@ -24,10 +26,11 @@ const showEnableTotpModal = (
|
|||||||
qrCode: string;
|
qrCode: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
},
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={4}>Enable TOTP</Title>,
|
title: t("account.modal.totp.title"),
|
||||||
children: (
|
children: (
|
||||||
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
|
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
|
||||||
),
|
),
|
||||||
@@ -46,6 +49,7 @@ const CreateEnableTotpModal = ({
|
|||||||
refreshUser: () => {};
|
refreshUser: () => {};
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
code: yup
|
code: yup
|
||||||
@@ -67,14 +71,19 @@ const CreateEnableTotpModal = ({
|
|||||||
<div>
|
<div>
|
||||||
<Center>
|
<Center>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>Step 1: Add your authenticator</Text>
|
<Text>
|
||||||
|
<FormattedMessage id="account.modal.totp.step1" />
|
||||||
|
</Text>
|
||||||
<Image src={options.qrCode} alt="QR Code" />
|
<Image src={options.qrCode} alt="QR Code" />
|
||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
<span>OR</span>
|
<span>
|
||||||
|
{" "}
|
||||||
|
<FormattedMessage id="common.text.or" />
|
||||||
|
</span>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
<Tooltip label="Click to copy">
|
<Tooltip label={t("account.modal.totp.clickToCopy")}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(options.secret);
|
navigator.clipboard.writeText(options.secret);
|
||||||
@@ -85,38 +94,42 @@ const CreateEnableTotpModal = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Center>
|
<Center>
|
||||||
<Text fz="xs">Enter manually</Text>
|
<Text fz="xs"></Text>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
<Text>Step 2: Validate your code</Text>
|
<Text>
|
||||||
|
<FormattedMessage id="account.modal.totp.step2" />
|
||||||
|
</Text>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
authService
|
authService
|
||||||
.verifyTOTP(values.code, options.password)
|
.verifyTOTP(values.code, options.password)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Successfully enabled TOTP");
|
toast.success(t("account.notify.totp.enable"));
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
refreshUser();
|
refreshUser();
|
||||||
})
|
})
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Grid align="flex-end">
|
<Group align="end">
|
||||||
<Col xs={9}>
|
<TextInput
|
||||||
<TextInput
|
style={{ flex: "1" }}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Code"
|
label={t("account.modal.totp.code")}
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
{...form.getInputProps("code")}
|
{...form.getInputProps("code")}
|
||||||
/>
|
/>
|
||||||
</Col>
|
|
||||||
<Col xs={3}>
|
<Button
|
||||||
<Button variant="outline" type="submit">
|
style={{ flex: "0 0 auto" }}
|
||||||
Verify
|
variant="outline"
|
||||||
</Button>
|
type="submit"
|
||||||
</Col>
|
>
|
||||||
</Grid>
|
<FormattedMessage id="account.modal.totp.verify" />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||||
|
|
||||||
|
const showReverseShareLinkModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
reverseShareToken: string,
|
||||||
|
appUrl: string,
|
||||||
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
|
const link = `${appUrl}/upload/${reverseShareToken}`;
|
||||||
|
return modals.openModal({
|
||||||
|
title: t("account.reverseShares.modal.reverse-share-link"),
|
||||||
|
children: (
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput variant="filled" value={link} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showReverseShareLinkModal;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Divider, Flex, Progress, Stack, Text } from "@mantine/core";
|
||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import moment from "moment";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||||
|
import { FileMetaData } from "../../types/File.type";
|
||||||
|
import { MyShare } from "../../types/share.type";
|
||||||
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
|
import CopyTextField from "../upload/CopyTextField";
|
||||||
|
|
||||||
|
const showShareInformationsModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
share: MyShare,
|
||||||
|
appUrl: string,
|
||||||
|
maxShareSize: number,
|
||||||
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
|
const link = `${appUrl}/s/${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: t("account.shares.modal.share-informations"),
|
||||||
|
|
||||||
|
children: (
|
||||||
|
<Stack align="stretch" spacing="md">
|
||||||
|
<Text size="sm" color="lightgray">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.id" />:{" "}
|
||||||
|
</b>
|
||||||
|
{share.id}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="sm" color="lightgray">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.description" />:{" "}
|
||||||
|
</b>
|
||||||
|
{share.description || "No description"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="sm" color="lightgray">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
|
||||||
|
</b>
|
||||||
|
{formattedCreatedAt}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text size="sm" color="lightgray">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.expiresAt" />:{" "}
|
||||||
|
</b>
|
||||||
|
{formattedExpiration}
|
||||||
|
</Text>
|
||||||
|
<Divider />
|
||||||
|
<CopyTextField link={link} />
|
||||||
|
<Divider />
|
||||||
|
<Text size="sm" color="lightgray">
|
||||||
|
<b>
|
||||||
|
<FormattedMessage id="account.shares.table.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;
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Stack, TextInput } from "@mantine/core";
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||||
|
|
||||||
const showShareLinkModal = (
|
const showShareLinkModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
shareId: string,
|
shareId: string,
|
||||||
appUrl: string
|
appUrl: string,
|
||||||
) => {
|
) => {
|
||||||
const link = `${appUrl}/share/${shareId}`;
|
const t = translateOutsideContext();
|
||||||
|
const link = `${appUrl}/s/${shareId}`;
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: "Share link",
|
title: t("account.shares.modal.share-link"),
|
||||||
children: (
|
children: (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput variant="filled" value={link} />
|
<TextInput variant="filled" value={link} />
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ const AdminConfigInput = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
stringValue: configVariable.value,
|
stringValue: configVariable.value ?? configVariable.defaultValue,
|
||||||
textValue: configVariable.value,
|
textValue: configVariable.value ?? configVariable.defaultValue,
|
||||||
numberValue: parseInt(configVariable.value),
|
numberValue: parseInt(
|
||||||
booleanValue: configVariable.value == "true",
|
configVariable.value ?? configVariable.defaultValue,
|
||||||
|
),
|
||||||
|
booleanValue:
|
||||||
|
(configVariable.value ?? configVariable.defaultValue) == "true",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,29 +38,38 @@ const AdminConfigInput = ({
|
|||||||
{configVariable.type == "string" &&
|
{configVariable.type == "string" &&
|
||||||
(configVariable.obscured ? (
|
(configVariable.obscured ? (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
style={{ width: "100%" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
{...form.getInputProps("stringValue")}
|
{...form.getInputProps("stringValue")}
|
||||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
style={{ width: "100%" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
{...form.getInputProps("stringValue")}
|
{...form.getInputProps("stringValue")}
|
||||||
|
placeholder={configVariable.defaultValue}
|
||||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{configVariable.type == "text" && (
|
{configVariable.type == "text" && (
|
||||||
<Textarea
|
<Textarea
|
||||||
style={{ width: "100%" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
autosize
|
autosize
|
||||||
{...form.getInputProps("textValue")}
|
{...form.getInputProps("textValue")}
|
||||||
|
placeholder={configVariable.defaultValue}
|
||||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{configVariable.type == "number" && (
|
{configVariable.type == "number" && (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
{...form.getInputProps("numberValue")}
|
{...form.getInputProps("numberValue")}
|
||||||
|
placeholder={configVariable.defaultValue}
|
||||||
onChange={(number) => onValueChange(configVariable, number)}
|
onChange={(number) => onValueChange(configVariable, number)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import useConfig from "../../../hooks/config.hook";
|
import useConfig from "../../../hooks/config.hook";
|
||||||
import Logo from "../../Logo";
|
import Logo from "../../Logo";
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ const ConfigurationHeader = ({
|
|||||||
</Link>
|
</Link>
|
||||||
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
|
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
|
||||||
<Button variant="light" component={Link} href="/admin">
|
<Button variant="light" component={Link} href="/admin">
|
||||||
Go back
|
<FormattedMessage id="common.button.go-back" />
|
||||||
</Button>
|
</Button>
|
||||||
</MediaQuery>
|
</MediaQuery>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
import { Dispatch, SetStateAction } from "react";
|
||||||
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ name: "General", icon: <TbSquare /> },
|
{ name: "General", icon: <TbSquare /> },
|
||||||
@@ -53,7 +54,7 @@ const ConfigurationNavBar = ({
|
|||||||
>
|
>
|
||||||
<Navbar.Section>
|
<Navbar.Section>
|
||||||
<Text size="xs" color="dimmed" mb="sm">
|
<Text size="xs" color="dimmed" mb="sm">
|
||||||
Configuration
|
<FormattedMessage id="admin.config.title" />
|
||||||
</Text>
|
</Text>
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
@@ -79,7 +80,11 @@ const ConfigurationNavBar = ({
|
|||||||
>
|
>
|
||||||
{category.icon}
|
{category.icon}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm">{category.name}</Text>
|
<Text size="sm">
|
||||||
|
<FormattedMessage
|
||||||
|
id={`admin.config.category.${category.name.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -87,7 +92,7 @@ const ConfigurationNavBar = ({
|
|||||||
</Navbar.Section>
|
</Navbar.Section>
|
||||||
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||||
<Button mt="xl" variant="light" component={Link} href="/admin">
|
<Button mt="xl" variant="light" component={Link} href="/admin">
|
||||||
Go back
|
<FormattedMessage id="common.button.go-back" />
|
||||||
</Button>
|
</Button>
|
||||||
</MediaQuery>
|
</MediaQuery>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
|
|
||||||
|
const LogoConfigInput = ({
|
||||||
|
logo,
|
||||||
|
setLogo,
|
||||||
|
}: {
|
||||||
|
logo: File | null;
|
||||||
|
setLogo: Dispatch<SetStateAction<File | null>>;
|
||||||
|
}) => {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group position="apart">
|
||||||
|
<Stack style={{ maxWidth: isMobile ? "100%" : "40%" }} spacing={0}>
|
||||||
|
<Title order={6}>
|
||||||
|
<FormattedMessage id="admin.config.general.logo" />
|
||||||
|
</Title>
|
||||||
|
<Text color="dimmed" size="sm" mb="xs">
|
||||||
|
<FormattedMessage id="admin.config.general.logo.description" />
|
||||||
|
</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={t("admin.config.general.logo.placeholder")}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogoConfigInput;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import useUser from "../../../hooks/user.hook";
|
import useUser from "../../../hooks/user.hook";
|
||||||
import configService from "../../../services/config.service";
|
import configService from "../../../services/config.service";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
@@ -32,7 +33,7 @@ const TestEmailButton = ({
|
|||||||
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ const TestEmailButton = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Send test email
|
<FormattedMessage id="admin.config.smtp.button.test" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useModals } from "@mantine/modals";
|
|||||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||||
import User from "../../../types/user.type";
|
import User from "../../../types/user.type";
|
||||||
import showUpdateUserModal from "./showUpdateUserModal";
|
import showUpdateUserModal from "./showUpdateUserModal";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
|
||||||
const ManageUserTable = ({
|
const ManageUserTable = ({
|
||||||
users,
|
users,
|
||||||
@@ -22,9 +23,15 @@ const ManageUserTable = ({
|
|||||||
<Table verticalSpacing="sm">
|
<Table verticalSpacing="sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>
|
||||||
<th>Email</th>
|
<FormattedMessage id="admin.users.table.username" />
|
||||||
<th>Admin</th>
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="admin.users.table.email" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="admin.users.table.admin" />
|
||||||
|
</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
import userService from "../../../services/user.service";
|
import userService from "../../../services/user.service";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showCreateUserModal = (
|
const showCreateUserModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
smtpEnabled: boolean,
|
smtpEnabled: boolean,
|
||||||
getUsers: () => void
|
getUsers: () => void,
|
||||||
) => {
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={5}>Create user</Title>,
|
title: "Create user",
|
||||||
children: (
|
children: (
|
||||||
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
||||||
),
|
),
|
||||||
@@ -35,6 +36,7 @@ const Body = ({
|
|||||||
smtpEnabled: boolean;
|
smtpEnabled: boolean;
|
||||||
getUsers: () => void;
|
getUsers: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useTranslate();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -45,10 +47,15 @@ const Body = ({
|
|||||||
},
|
},
|
||||||
validate: yupResolver(
|
validate: yupResolver(
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
email: yup.string().email(),
|
email: yup.string().email(t("common.error.invalid-email")),
|
||||||
username: yup.string().min(3),
|
username: yup
|
||||||
password: yup.string().min(8).optional(),
|
.string()
|
||||||
})
|
.min(3, t("common.error.too-short", { length: 3 })),
|
||||||
|
password: yup
|
||||||
|
.string()
|
||||||
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,26 +73,33 @@ const Body = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label="Username" {...form.getInputProps("username")} />
|
<TextInput
|
||||||
<TextInput label="Email" {...form.getInputProps("email")} />
|
label={t("admin.users.modal.create.username")}
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t("admin.users.modal.create.email")}
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
{smtpEnabled && (
|
{smtpEnabled && (
|
||||||
<Switch
|
<Switch
|
||||||
mt="xs"
|
mt="xs"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
label="Set password manually"
|
label={t("admin.users.modal.create.manual-password")}
|
||||||
description="If not checked, the user will receive an email with a link to set their password."
|
description={t(
|
||||||
|
"admin.users.modal.create.manual-password.description",
|
||||||
|
)}
|
||||||
{...form.getInputProps("setPasswordManually", {
|
{...form.getInputProps("setPasswordManually", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{form.values.setPasswordManually ||
|
{(form.values.setPasswordManually || !smtpEnabled) && (
|
||||||
(!smtpEnabled && (
|
<PasswordInput
|
||||||
<PasswordInput
|
label={t("admin.users.modal.create.password")}
|
||||||
label="Password"
|
{...form.getInputProps("password")}
|
||||||
{...form.getInputProps("password")}
|
/>
|
||||||
/>
|
)}
|
||||||
))}
|
|
||||||
<Switch
|
<Switch
|
||||||
styles={{
|
styles={{
|
||||||
body: {
|
body: {
|
||||||
@@ -95,12 +109,14 @@ const Body = ({
|
|||||||
}}
|
}}
|
||||||
mt="xs"
|
mt="xs"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
label="Admin privileges"
|
label={t("admin.users.modal.create.admin")}
|
||||||
description="If checked, the user will be able to access the admin panel."
|
description={t("admin.users.modal.create.admin.description")}
|
||||||
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit">
|
||||||
|
<FormattedMessage id="common.button.create" />
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import userService from "../../../services/user.service";
|
import userService from "../../../services/user.service";
|
||||||
import User from "../../../types/user.type";
|
import User from "../../../types/user.type";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
@@ -18,10 +21,11 @@ import toast from "../../../utils/toast.util";
|
|||||||
const showUpdateUserModal = (
|
const showUpdateUserModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
user: User,
|
user: User,
|
||||||
getUsers: () => void
|
getUsers: () => void,
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={5}>Update {user.username}</Title>,
|
title: t("admin.users.edit.update.title", { username: user.username }),
|
||||||
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -35,6 +39,8 @@ const Body = ({
|
|||||||
user: User;
|
user: User;
|
||||||
getUsers: () => void;
|
getUsers: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
const accountForm = useForm({
|
const accountForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -43,9 +49,11 @@ const Body = ({
|
|||||||
},
|
},
|
||||||
validate: yupResolver(
|
validate: yupResolver(
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
email: yup.string().email(),
|
email: yup.string().email(t("common.error.invalid-email")),
|
||||||
username: yup.string().min(3),
|
username: yup
|
||||||
})
|
.string()
|
||||||
|
.min(3, t("common.error.too-short", { length: 3 })),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,8 +63,10 @@ const Body = ({
|
|||||||
},
|
},
|
||||||
validate: yupResolver(
|
validate: yupResolver(
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
password: yup.string().min(8),
|
password: yup
|
||||||
})
|
.string()
|
||||||
|
.min(8, t("common.error.too-short", { length: 8 })),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,21 +86,26 @@ const Body = ({
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Username"
|
label={t("admin.users.table.username")}
|
||||||
{...accountForm.getInputProps("username")}
|
{...accountForm.getInputProps("username")}
|
||||||
/>
|
/>
|
||||||
<TextInput label="Email" {...accountForm.getInputProps("email")} />
|
<TextInput
|
||||||
|
label={t("admin.users.table.email")}
|
||||||
|
{...accountForm.getInputProps("email")}
|
||||||
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
mt="xs"
|
mt="xs"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
label="Admin privileges"
|
label={t("admin.users.edit.update.admin-privileges")}
|
||||||
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
|
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
||||||
<Accordion.Control px={0}>Change password</Accordion.Control>
|
<Accordion.Control px={0}>
|
||||||
|
<FormattedMessage id="admin.users.edit.update.change-password.title" />
|
||||||
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<form
|
<form
|
||||||
onSubmit={passwordForm.onSubmit(async (values) => {
|
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||||
@@ -98,17 +113,21 @@ const Body = ({
|
|||||||
.update(user.id, {
|
.update(user.id, {
|
||||||
password: values.password,
|
password: values.password,
|
||||||
})
|
})
|
||||||
.then(() => toast.success("Password changed successfully"))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
t("admin.users.edit.update.notify.password.success"),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="New password"
|
label={t("admin.users.edit.update.change-password.field")}
|
||||||
{...passwordForm.getInputProps("password")}
|
{...passwordForm.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button variant="light" type="submit">
|
<Button variant="light" type="submit">
|
||||||
Save new password
|
<FormattedMessage id="admin.users.edit.update.change-password.button" />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
@@ -117,7 +136,7 @@ const Body = ({
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<Button type="submit" form="accountForm">
|
<Button type="submit" form="accountForm">
|
||||||
Save
|
<FormattedMessage id="common.button.save" />
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TbInfoCircle } from "react-icons/tb";
|
import { TbInfoCircle } from "react-icons/tb";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
@@ -24,19 +26,18 @@ import toast from "../../utils/toast.util";
|
|||||||
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslate();
|
||||||
const { refreshUser } = useUser();
|
const { refreshUser } = useUser();
|
||||||
|
|
||||||
const [showTotp, setShowTotp] = React.useState(false);
|
const [showTotp, setShowTotp] = React.useState(false);
|
||||||
const [loginToken, setLoginToken] = React.useState("");
|
const [loginToken, setLoginToken] = React.useState("");
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
emailOrUsername: yup.string().required(),
|
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||||
password: yup.string().min(8).required(),
|
password: yup
|
||||||
totp: yup.string().when("totpRequired", {
|
.string()
|
||||||
is: true,
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
then: yup.string().min(6).max(6).required(),
|
.required(t("common.error.field-required")),
|
||||||
otherwise: yup.string(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -59,8 +60,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
icon: <TbInfoCircle />,
|
icon: <TbInfoCircle />,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
radius: "md",
|
radius: "md",
|
||||||
title: "Two-factor authentication required",
|
title: t("signIn.notify.totp-required.title"),
|
||||||
message: "Please enter your two-factor authentication code",
|
message: t("signIn.notify.totp-required.description"),
|
||||||
});
|
});
|
||||||
setLoginToken(response.data["loginToken"]);
|
setLoginToken(response.data["loginToken"]);
|
||||||
} else {
|
} else {
|
||||||
@@ -79,8 +80,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
router.replace(redirectPath);
|
router.replace(redirectPath);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error?.response?.data?.message == "Login token expired") {
|
if (error?.response?.data?.error == "share_password_required") {
|
||||||
toast.error("Login token expired");
|
toast.axiosError(error);
|
||||||
// Refresh the page to start over
|
// Refresh the page to start over
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -93,13 +94,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title order={2} align="center" weight={900}>
|
<Title order={2} align="center" weight={900}>
|
||||||
Welcome back
|
<FormattedMessage id="signin.title" />
|
||||||
</Title>
|
</Title>
|
||||||
{config.get("share.allowRegistration") && (
|
{config.get("share.allowRegistration") && (
|
||||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
You don't have an account yet?{" "}
|
<FormattedMessage id="signin.description" />{" "}
|
||||||
<Anchor component={Link} href={"signUp"} size="sm">
|
<Anchor component={Link} href={"signUp"} size="sm">
|
||||||
{"Sign up"}
|
<FormattedMessage id="signin.button.signup" />
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -112,20 +113,20 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email or username"
|
label={t("signin.input.email-or-username")}
|
||||||
placeholder="Your email or username"
|
placeholder={t("signin.input.email-or-username.placeholder")}
|
||||||
{...form.getInputProps("emailOrUsername")}
|
{...form.getInputProps("emailOrUsername")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("signin.input.password")}
|
||||||
placeholder="Your password"
|
placeholder={t("signin.input.password.placeholder")}
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
{showTotp && (
|
{showTotp && (
|
||||||
<TextInput
|
<TextInput
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Code"
|
label={t("account.modal.totp.code")}
|
||||||
placeholder="******"
|
placeholder="******"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("totp")}
|
{...form.getInputProps("totp")}
|
||||||
@@ -134,12 +135,12 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
{config.get("smtp.enabled") && (
|
{config.get("smtp.enabled") && (
|
||||||
<Group position="right" mt="xs">
|
<Group position="right" mt="xs">
|
||||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||||
Forgot password?
|
<FormattedMessage id="resetPassword.title" />
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<Button fullWidth mt="xl" type="submit">
|
<Button fullWidth mt="xl" type="submit">
|
||||||
Sign in
|
<FormattedMessage id="signin.button.submit" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
@@ -20,12 +22,19 @@ import toast from "../../utils/toast.util";
|
|||||||
const SignUpForm = () => {
|
const SignUpForm = () => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslate();
|
||||||
const { refreshUser } = useUser();
|
const { refreshUser } = useUser();
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
email: yup.string().email().required(),
|
email: yup.string().email(t("common.error.invalid-email")).required(),
|
||||||
username: yup.string().min(3).required(),
|
username: yup
|
||||||
password: yup.string().min(8).required(),
|
.string()
|
||||||
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
|
.required(t("common.error.field-required")),
|
||||||
|
password: yup
|
||||||
|
.string()
|
||||||
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
|
.required(t("common.error.field-required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -54,41 +63,41 @@ const SignUpForm = () => {
|
|||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title order={2} align="center" weight={900}>
|
<Title order={2} align="center" weight={900}>
|
||||||
Sign up
|
<FormattedMessage id="signup.title" />
|
||||||
</Title>
|
</Title>
|
||||||
{config.get("share.allowRegistration") && (
|
{config.get("share.allowRegistration") && (
|
||||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||||
You have an account already?{" "}
|
<FormattedMessage id="signup.description" />{" "}
|
||||||
<Anchor component={Link} href={"signIn"} size="sm">
|
<Anchor component={Link} href={"signIn"} size="sm">
|
||||||
Sign in
|
<FormattedMessage id="signup.button.signin" />
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) =>
|
onSubmit={form.onSubmit((values) =>
|
||||||
signUp(values.email, values.username, values.password)
|
signUp(values.email, values.username, values.password),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Username"
|
label={t("signup.input.username")}
|
||||||
placeholder="Your username"
|
placeholder={t("signup.input.username.placeholder")}
|
||||||
{...form.getInputProps("username")}
|
{...form.getInputProps("username")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label={t("signup.input.email")}
|
||||||
placeholder="Your email"
|
placeholder={t("signup.input.email.placeholder")}
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("email")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label={t("signin.input.password")}
|
||||||
placeholder="Your password"
|
placeholder={t("signin.input.password.placeholder")}
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button fullWidth mt="xl" type="submit">
|
<Button fullWidth mt="xl" type="submit">
|
||||||
Let's get started
|
<FormattedMessage id="signup.button.submit" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
41
frontend/src/components/core/SortIcon.tsx
Normal file
41
frontend/src/components/core/SortIcon.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ActionIcon } from "@mantine/core";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
import { TbChevronDown, TbChevronUp, TbSelector } from "react-icons/tb";
|
||||||
|
|
||||||
|
export type TableSort = {
|
||||||
|
property?: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
|
const TableSortIcon = ({
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
property,
|
||||||
|
}: {
|
||||||
|
sort: TableSort;
|
||||||
|
setSort: Dispatch<SetStateAction<TableSort>>;
|
||||||
|
property: string;
|
||||||
|
}) => {
|
||||||
|
if (sort.property === property) {
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() =>
|
||||||
|
setSort({
|
||||||
|
property,
|
||||||
|
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.direction === "asc" ? <TbChevronDown /> : <TbChevronUp />}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<ActionIcon onClick={() => setSort({ property, direction: "asc" })}>
|
||||||
|
<TbSelector />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableSortIcon;
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb";
|
import { TbDoorExit, TbSettings, TbUser } from "react-icons/tb";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
|
||||||
const ActionAvatar = () => {
|
const ActionAvatar = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
@@ -16,7 +17,7 @@ const ActionAvatar = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||||
My account
|
<FormattedMessage id="navbar.avatar.account" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{user!.isAdmin && (
|
{user!.isAdmin && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -24,7 +25,7 @@ const ActionAvatar = () => {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
icon={<TbSettings size={14} />}
|
icon={<TbSettings size={14} />}
|
||||||
>
|
>
|
||||||
Administration
|
<FormattedMessage id="navbar.avatar.admin" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ const ActionAvatar = () => {
|
|||||||
}}
|
}}
|
||||||
icon={<TbDoorExit size={14} />}
|
icon={<TbDoorExit size={14} />}
|
||||||
>
|
>
|
||||||
Sign out
|
<FormattedMessage id="navbar.avatar.signout" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/router";
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import Logo from "../Logo";
|
import Logo from "../Logo";
|
||||||
import ActionAvatar from "./ActionAvatar";
|
import ActionAvatar from "./ActionAvatar";
|
||||||
import NavbarShareMenu from "./NavbarShareMenu";
|
import NavbarShareMenu from "./NavbarShareMenu";
|
||||||
@@ -112,6 +113,7 @@ const Header = () => {
|
|||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
const [opened, toggleOpened] = useDisclosure(false);
|
const [opened, toggleOpened] = useDisclosure(false);
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ const Header = () => {
|
|||||||
const authenticatedLinks: NavLink[] = [
|
const authenticatedLinks: NavLink[] = [
|
||||||
{
|
{
|
||||||
link: "/upload",
|
link: "/upload",
|
||||||
label: "Upload",
|
label: t("navbar.upload"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: <NavbarShareMenu />,
|
component: <NavbarShareMenu />,
|
||||||
@@ -137,27 +139,27 @@ const Header = () => {
|
|||||||
let unauthenticatedLinks: NavLink[] = [
|
let unauthenticatedLinks: NavLink[] = [
|
||||||
{
|
{
|
||||||
link: "/auth/signIn",
|
link: "/auth/signIn",
|
||||||
label: "Sign in",
|
label: t("navbar.signin"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.get("share.allowUnauthenticatedShares")) {
|
if (config.get("share.allowUnauthenticatedShares")) {
|
||||||
unauthenticatedLinks.unshift({
|
unauthenticatedLinks.unshift({
|
||||||
link: "/upload",
|
link: "/upload",
|
||||||
label: "Upload",
|
label: t("navbar.upload"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.get("general.showHomePage"))
|
if (config.get("general.showHomePage"))
|
||||||
unauthenticatedLinks.unshift({
|
unauthenticatedLinks.unshift({
|
||||||
link: "/",
|
link: "/",
|
||||||
label: "Home",
|
label: t("navbar.home"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.get("share.allowRegistration"))
|
if (config.get("share.allowRegistration"))
|
||||||
unauthenticatedLinks.push({
|
unauthenticatedLinks.push({
|
||||||
link: "/auth/signUp",
|
link: "/auth/signUp",
|
||||||
label: "Sign up",
|
label: t("navbar.signup"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { classes, cx } = useStyles();
|
const { classes, cx } = useStyles();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ActionIcon, Menu } from "@mantine/core";
|
import { ActionIcon, Menu } from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
|
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const NavbarShareMneu = () => {
|
const NavbarShareMneu = () => {
|
||||||
return (
|
return (
|
||||||
@@ -12,14 +13,14 @@ const NavbarShareMneu = () => {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item component={Link} href="/account/shares" icon={<TbLink />}>
|
<Menu.Item component={Link} href="/account/shares" icon={<TbLink />}>
|
||||||
My shares
|
<FormattedMessage id="navbar.links.shares" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
href="/account/reverseShares"
|
href="/account/reverseShares"
|
||||||
icon={<TbArrowLoopLeft />}
|
icon={<TbArrowLoopLeft />}
|
||||||
>
|
>
|
||||||
Reverse shares
|
<FormattedMessage id="navbar.links.reverse" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const DownloadAllButton = ({ shareId }: { shareId: string }) => {
|
const DownloadAllButton = ({ shareId }: { shareId: string }) => {
|
||||||
const [isZipReady, setIsZipReady] = useState(false);
|
const [isZipReady, setIsZipReady] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
const downloadAll = async () => {
|
const downloadAll = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await shareService
|
await shareService
|
||||||
@@ -39,13 +43,13 @@ const DownloadAllButton = ({ shareId }: { shareId: string }) => {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isZipReady) {
|
if (!isZipReady) {
|
||||||
toast.error("The share is preparing. Try again in a few minutes.");
|
toast.error(t("share.notify.download-all-preparing"));
|
||||||
} else {
|
} else {
|
||||||
downloadAll();
|
downloadAll();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download all
|
<FormattedMessage id="share.button.download-all" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Box,
|
||||||
Group,
|
Group,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -8,9 +9,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@mantine/hooks";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import mime from "mime-types";
|
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
@@ -18,19 +17,52 @@ import { FileMetaData } from "../../types/File.type";
|
|||||||
import { Share } from "../../types/share.type";
|
import { Share } from "../../types/share.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
import TableSortIcon, { TableSort } from "../core/SortIcon";
|
||||||
|
import showFilePreviewModal from "./modals/showFilePreviewModal";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const FileList = ({
|
const FileList = ({
|
||||||
files,
|
files,
|
||||||
|
setShare,
|
||||||
share,
|
share,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
files?: FileMetaData[];
|
files?: FileMetaData[];
|
||||||
|
setShare: Dispatch<SetStateAction<Share | undefined>>;
|
||||||
share: Share;
|
share: Share;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
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 copyFileLink = (file: FileMetaData) => {
|
||||||
const link = `${config.get("general.appUrl")}/api/shares/${
|
const link = `${config.get("general.appUrl")}/api/shares/${
|
||||||
@@ -39,10 +71,10 @@ const FileList = ({
|
|||||||
|
|
||||||
if (window.isSecureContext) {
|
if (window.isSecureContext) {
|
||||||
clipboard.copy(link);
|
clipboard.copy(link);
|
||||||
toast.success("Your file link was copied to the keyboard.");
|
toast.success(t("common.notify.copied"));
|
||||||
} else {
|
} else {
|
||||||
modals.openModal({
|
modals.openModal({
|
||||||
title: "File link",
|
title: t("share.modal.file-link"),
|
||||||
children: (
|
children: (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput variant="filled" value={link} />
|
<TextInput variant="filled" value={link} />
|
||||||
@@ -52,55 +84,70 @@ const FileList = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(sortFiles, [sort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||||
<thead>
|
<Table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Name</th>
|
<tr>
|
||||||
<th>Size</th>
|
<th>
|
||||||
<th></th>
|
<Group spacing="xs">
|
||||||
</tr>
|
<FormattedMessage id="share.table.name" />
|
||||||
</thead>
|
<TableSortIcon sort={sort} setSort={setSort} property="name" />
|
||||||
<tbody>
|
</Group>
|
||||||
{isLoading
|
</th>
|
||||||
? skeletonRows
|
<th>
|
||||||
: files!.map((file) => (
|
<Group spacing="xs">
|
||||||
<tr key={file.name}>
|
<FormattedMessage id="share.table.size" />
|
||||||
<td>{file.name}</td>
|
<TableSortIcon sort={sort} setSort={setSort} property="size" />
|
||||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
</Group>
|
||||||
<td>
|
</th>
|
||||||
<Group position="right">
|
<th></th>
|
||||||
{shareService.doesFileSupportPreview(file.name) && (
|
</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
|
<ActionIcon
|
||||||
component={Link}
|
|
||||||
href={`/share/${share.id}/preview/${
|
|
||||||
file.id
|
|
||||||
}?type=${mime.contentType(file.name)}`}
|
|
||||||
target="_blank"
|
|
||||||
size={25}
|
size={25}
|
||||||
|
onClick={async () => {
|
||||||
|
await shareService.downloadFile(share.id, file.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TbEye />
|
<TbDownload />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
</Group>
|
||||||
{!share.hasPassword && (
|
</td>
|
||||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
</tr>
|
||||||
<TbLink />
|
))}
|
||||||
</ActionIcon>
|
</tbody>
|
||||||
)}
|
</Table>
|
||||||
<ActionIcon
|
</Box>
|
||||||
size={25}
|
|
||||||
onClick={async () => {
|
|
||||||
await shareService.downloadFile(share.id, file.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TbDownload />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
160
frontend/src/components/share/FilePreview.tsx
Normal file
160
frontend/src/components/share/FilePreview.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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 { FormattedMessage } from "react-intl";
|
||||||
|
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
|
||||||
|
{/* Add translation? */}
|
||||||
|
</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}>
|
||||||
|
<FormattedMessage id="share.modal.file-preview.error.not-supported.title" />
|
||||||
|
</Title>
|
||||||
|
<Text>
|
||||||
|
<FormattedMessage id="share.modal.file-preview.error.not-supported.description" />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
||||||
@@ -34,8 +34,10 @@ const FileSizeInput = ({
|
|||||||
label={label}
|
label={label}
|
||||||
value={size}
|
value={size}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSize(value!);
|
if (value) {
|
||||||
onChange(unitAndSizeToByte(unit, value!));
|
setSize(value);
|
||||||
|
onChange(unitAndSizeToByte(unit, value));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import { TbCopy } from "react-icons/tb";
|
import { FormattedMessage } from "react-intl";
|
||||||
import toast from "../../../utils/toast.util";
|
import { translateOutsideContext } from "../../../hooks/useTranslate.hook";
|
||||||
|
import CopyTextField from "../../upload/CopyTextField";
|
||||||
|
|
||||||
const showCompletedReverseShareModal = (
|
const showCompletedReverseShareModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
link: string,
|
link: string,
|
||||||
getReverseShares: () => void
|
getReverseShares: () => void,
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
title: (
|
title: t("account.reverseShares.modal.reverse-share-link"),
|
||||||
<Stack align="stretch" spacing={0}>
|
|
||||||
<Title order={4}>Reverse share link</Title>
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
children: <Body link={link} getReverseShares={getReverseShares} />,
|
children: <Body link={link} getReverseShares={getReverseShares} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -30,28 +27,11 @@ const Body = ({
|
|||||||
link: string;
|
link: string;
|
||||||
getReverseShares: () => void;
|
getReverseShares: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput
|
<CopyTextField link={link} />
|
||||||
readOnly
|
|
||||||
variant="filled"
|
|
||||||
value={link}
|
|
||||||
rightSection={
|
|
||||||
window.isSecureContext && (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => {
|
|
||||||
clipboard.copy(link);
|
|
||||||
toast.success("Your link was copied to the keyboard.");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TbCopy />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -59,7 +39,7 @@ const Body = ({
|
|||||||
getReverseShares();
|
getReverseShares();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Done
|
<FormattedMessage id="common.button.done" />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import shareService from "../../../services/share.service";
|
import shareService from "../../../services/share.service";
|
||||||
import { getExpirationPreview } from "../../../utils/date.util";
|
import { getExpirationPreview } from "../../../utils/date.util";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
@@ -22,10 +25,11 @@ import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
|||||||
const showCreateReverseShareModal = (
|
const showCreateReverseShareModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
showSendEmailNotificationOption: boolean,
|
showSendEmailNotificationOption: boolean,
|
||||||
getReverseShares: () => void
|
getReverseShares: () => void,
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={4}>Create reverse share</Title>,
|
title: t("account.reverseShares.modal.title"),
|
||||||
children: (
|
children: (
|
||||||
<Body
|
<Body
|
||||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||||
@@ -43,6 +47,7 @@ const Body = ({
|
|||||||
showSendEmailNotificationOption: boolean;
|
showSendEmailNotificationOption: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -62,7 +67,7 @@ const Body = ({
|
|||||||
values.expiration_num + values.expiration_unit,
|
values.expiration_num + values.expiration_unit,
|
||||||
values.maxShareSize,
|
values.maxShareSize,
|
||||||
values.maxUseCount,
|
values.maxUseCount,
|
||||||
values.sendEmailNotification
|
values.sendEmailNotification,
|
||||||
)
|
)
|
||||||
.then(({ link }) => {
|
.then(({ link }) => {
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
@@ -80,7 +85,7 @@ const Body = ({
|
|||||||
max={99999}
|
max={99999}
|
||||||
precision={0}
|
precision={0}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Share expiration"
|
label={t("account.reverseShares.modal.expiration.label")}
|
||||||
{...form.getInputProps("expiration_num")}
|
{...form.getInputProps("expiration_num")}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -92,27 +97,44 @@ const Body = ({
|
|||||||
{
|
{
|
||||||
value: "-minutes",
|
value: "-minutes",
|
||||||
label:
|
label:
|
||||||
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.minute-singular")
|
||||||
|
: t("upload.modal.expires.minute-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-hours",
|
value: "-hours",
|
||||||
label:
|
label:
|
||||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.hour-singular")
|
||||||
|
: t("upload.modal.expires.hour-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-days",
|
value: "-days",
|
||||||
label:
|
label:
|
||||||
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.day-singular")
|
||||||
|
: t("upload.modal.expires.day-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-weeks",
|
value: "-weeks",
|
||||||
label:
|
label:
|
||||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.week-singular")
|
||||||
|
: t("upload.modal.expires.week-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-months",
|
value: "-months",
|
||||||
label:
|
label:
|
||||||
"Month" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.month-singular")
|
||||||
|
: t("upload.modal.expires.month-plural"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "-years",
|
||||||
|
label:
|
||||||
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.year-singular")
|
||||||
|
: t("upload.modal.expires.year-plural"),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -126,11 +148,17 @@ const Body = ({
|
|||||||
color: theme.colors.gray[6],
|
color: theme.colors.gray[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{getExpirationPreview("reverse share", form)}
|
{getExpirationPreview(
|
||||||
|
{
|
||||||
|
expiresOn: t("account.reverseShare.expires-on"),
|
||||||
|
neverExpires: t("account.reverseShare.never-expires"),
|
||||||
|
},
|
||||||
|
form,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<FileSizeInput
|
<FileSizeInput
|
||||||
label="Max share size"
|
label={t("account.reverseShares.modal.max-size.label")}
|
||||||
value={form.values.maxShareSize}
|
value={form.values.maxShareSize}
|
||||||
onChange={(number) => form.setFieldValue("maxShareSize", number)}
|
onChange={(number) => form.setFieldValue("maxShareSize", number)}
|
||||||
/>
|
/>
|
||||||
@@ -139,16 +167,18 @@ const Body = ({
|
|||||||
max={1000}
|
max={1000}
|
||||||
precision={0}
|
precision={0}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Max use count"
|
label={t("account.reverseShares.modal.max-use.label")}
|
||||||
description="The maximum number of times this reverse share link can be used"
|
description={t("account.reverseShares.modal.max-use.description")}
|
||||||
{...form.getInputProps("maxUseCount")}
|
{...form.getInputProps("maxUseCount")}
|
||||||
/>
|
/>
|
||||||
{showSendEmailNotificationOption && (
|
{showSendEmailNotificationOption && (
|
||||||
<Switch
|
<Switch
|
||||||
mt="xs"
|
mt="xs"
|
||||||
labelPosition="left"
|
labelPosition="left"
|
||||||
label="Send email notification"
|
label={t("account.reverseShares.modal.send-email")}
|
||||||
description="Send an email notification when a share is created with this reverse share link"
|
description={t(
|
||||||
|
"account.reverseShares.modal.send-email.description",
|
||||||
|
)}
|
||||||
{...form.getInputProps("sendEmailNotification", {
|
{...form.getInputProps("sendEmailNotification", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
})}
|
})}
|
||||||
@@ -156,7 +186,7 @@ const Body = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Button mt="md" type="submit">
|
<Button mt="md" type="submit">
|
||||||
Create
|
<FormattedMessage id="common.button.create" />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
import mime from "mime-types";
|
||||||
|
import { FileMetaData } from "../../../types/File.type";
|
||||||
|
import FilePreview from "../FilePreview";
|
||||||
|
|
||||||
|
const showFilePreviewModal = (
|
||||||
|
shareId: string,
|
||||||
|
file: FileMetaData,
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
) => {
|
||||||
|
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
|
||||||
|
return modals.openModal({
|
||||||
|
size: "xl",
|
||||||
|
title: file.name,
|
||||||
|
children: (
|
||||||
|
<FilePreview shareId={shareId} fileId={file.id} mimeType={mimeType} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default showFilePreviewModal;
|
||||||
@@ -1,57 +1,60 @@
|
|||||||
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 { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../hooks/useTranslate.hook";
|
||||||
|
|
||||||
const showEnterPasswordModal = (
|
const showEnterPasswordModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
submitCallback: any
|
submitCallback: (password: string) => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
title: (
|
title: t("share.modal.password.title"),
|
||||||
<>
|
|
||||||
<Title order={4}>Password required</Title>
|
|
||||||
<Text size="sm">
|
|
||||||
This access this share please enter the password for the share.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
children: <Body submitCallback={submitCallback} />,
|
children: <Body submitCallback={submitCallback} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ submitCallback }: { submitCallback: any }) => {
|
const Body = ({
|
||||||
|
submitCallback,
|
||||||
|
}: {
|
||||||
|
submitCallback: (password: string) => Promise<void>;
|
||||||
|
}) => {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passwordWrong, setPasswordWrong] = useState(false);
|
const [passwordWrong, setPasswordWrong] = useState(false);
|
||||||
|
const t = useTranslate();
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack align="stretch">
|
||||||
<Stack align="stretch">
|
<Text size="sm">
|
||||||
<PasswordInput
|
<FormattedMessage id="share.modal.password.description" />
|
||||||
variant="filled"
|
</Text>
|
||||||
placeholder="Password"
|
|
||||||
error={passwordWrong && "Wrong password"}
|
<form
|
||||||
onFocus={() => setPasswordWrong(false)}
|
onSubmit={(e) => {
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
e.preventDefault();
|
||||||
value={password}
|
submitCallback(password);
|
||||||
/>
|
}}
|
||||||
<Button
|
>
|
||||||
onClick={() =>
|
<Stack>
|
||||||
submitCallback(password)
|
<PasswordInput
|
||||||
.then((res: any) => res)
|
variant="filled"
|
||||||
.catch((e: any) => {
|
placeholder={t("share.modal.password")}
|
||||||
const error = e.response.data.message;
|
error={passwordWrong && t("share.modal.error.invalid-password")}
|
||||||
if (error == "Wrong password") {
|
onFocus={() => setPasswordWrong(false)}
|
||||||
setPasswordWrong(true);
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
}
|
value={password}
|
||||||
})
|
/>
|
||||||
}
|
<Button type="submit">
|
||||||
>
|
<FormattedMessage id="common.button.submit" />
|
||||||
Submit
|
</Button>
|
||||||
</Button>
|
</Stack>
|
||||||
</Stack>
|
</form>
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const showErrorModal = (
|
const showErrorModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
title: string,
|
title: string,
|
||||||
text: string
|
text: string,
|
||||||
) => {
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
title: <Title order={4}>{title}</Title>,
|
title: title,
|
||||||
|
|
||||||
children: <Body text={text} />,
|
children: <Body text={text} />,
|
||||||
});
|
});
|
||||||
@@ -31,7 +32,7 @@ const Body = ({ text }: { text: string }) => {
|
|||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Go back
|
<FormattedMessage id="common.button.go-back" />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
|
|||||||
51
frontend/src/components/upload/CopyTextField.tsx
Normal file
51
frontend/src/components/upload/CopyTextField.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { ActionIcon, TextInput } from "@mantine/core";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { TbCheck, TbCopy } from "react-icons/tb";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
|
function CopyTextField(props: { link: string }) {
|
||||||
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
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(t("common.notify.copied"));
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setCheckState(false);
|
||||||
|
}, 1500);
|
||||||
|
setCheckState(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
readOnly
|
||||||
|
label={t("common.text.link")}
|
||||||
|
variant="filled"
|
||||||
|
value={props.link}
|
||||||
|
onClick={() => {
|
||||||
|
if (!textClicked) {
|
||||||
|
copyLink();
|
||||||
|
setTextClicked(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
window.isSecureContext && (
|
||||||
|
<ActionIcon onClick={copyLink}>
|
||||||
|
{checkState ? <TbCheck /> : <TbCopy />}
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyTextField;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
||||||
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
||||||
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
import { ForwardedRef, useRef } from "react";
|
||||||
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import { FileUpload } from "../../types/File.type";
|
import { FileUpload } from "../../types/File.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
@@ -34,15 +35,13 @@ const useStyles = createStyles((theme) => ({
|
|||||||
const Dropzone = ({
|
const Dropzone = ({
|
||||||
isUploading,
|
isUploading,
|
||||||
maxShareSize,
|
maxShareSize,
|
||||||
files,
|
showCreateUploadModalCallback,
|
||||||
setFiles,
|
|
||||||
}: {
|
}: {
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
maxShareSize: number;
|
maxShareSize: number;
|
||||||
files: FileUpload[];
|
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
||||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
|
||||||
}) => {
|
}) => {
|
||||||
const config = useConfig();
|
const t = useTranslate();
|
||||||
|
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
const openRef = useRef<() => void>();
|
const openRef = useRef<() => void>();
|
||||||
@@ -54,24 +53,21 @@ const Dropzone = ({
|
|||||||
}}
|
}}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
openRef={openRef as ForwardedRef<() => void>}
|
openRef={openRef as ForwardedRef<() => void>}
|
||||||
onDrop={(newFiles: FileUpload[]) => {
|
onDrop={(files: FileUpload[]) => {
|
||||||
const fileSizeSum = [...newFiles, ...files].reduce(
|
const fileSizeSum = files.reduce((n, { size }) => n + size, 0);
|
||||||
(n, { size }) => n + size,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fileSizeSum > maxShareSize) {
|
if (fileSizeSum > maxShareSize) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Your files exceed the maximum share size of ${byteToHumanSizeString(
|
t("upload.dropzone.notify.file-too-big", {
|
||||||
maxShareSize
|
maxSize: byteToHumanSizeString(maxShareSize),
|
||||||
)}.`
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
newFiles = newFiles.map((newFile) => {
|
files = files.map((newFile) => {
|
||||||
newFile.uploadingProgress = 0;
|
newFile.uploadingProgress = 0;
|
||||||
return newFile;
|
return newFile;
|
||||||
});
|
});
|
||||||
setFiles([...newFiles, ...files]);
|
showCreateUploadModalCallback(files);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classes.dropzone}
|
className={classes.dropzone}
|
||||||
@@ -82,12 +78,13 @@ const Dropzone = ({
|
|||||||
<TbCloudUpload size={50} />
|
<TbCloudUpload size={50} />
|
||||||
</Group>
|
</Group>
|
||||||
<Text align="center" weight={700} size="lg" mt="xl">
|
<Text align="center" weight={700} size="lg" mt="xl">
|
||||||
Upload files
|
<FormattedMessage id="upload.dropzone.title" />
|
||||||
</Text>
|
</Text>
|
||||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||||
Drag'n'drop files here to start your share. We can accept
|
<FormattedMessage
|
||||||
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
|
id="upload.dropzone.description"
|
||||||
in total.
|
values={{ maxSize: byteToHumanSizeString(maxShareSize) }}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</MantineDropzone>
|
</MantineDropzone>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TbTrash } from "react-icons/tb";
|
|||||||
import { FileUpload } from "../../types/File.type";
|
import { FileUpload } from "../../types/File.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const FileList = ({
|
const FileList = ({
|
||||||
files,
|
files,
|
||||||
@@ -41,8 +42,12 @@ const FileList = ({
|
|||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>
|
||||||
<th>Size</th>
|
<FormattedMessage id="upload.filelist.name" />
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<FormattedMessage id="upload.filelist.size" />
|
||||||
|
</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,63 +1,40 @@
|
|||||||
import {
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
ActionIcon,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useClipboard } from "@mantine/hooks";
|
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { TbCopy } from "react-icons/tb";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import { Share } from "../../../types/share.type";
|
import { Share } from "../../../types/share.type";
|
||||||
import toast from "../../../utils/toast.util";
|
import CopyTextField from "../CopyTextField";
|
||||||
|
|
||||||
const showCompletedUploadModal = (
|
const showCompletedUploadModal = (
|
||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
share: Share,
|
share: Share,
|
||||||
appUrl: string
|
appUrl: string,
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
closeOnEscape: false,
|
closeOnEscape: false,
|
||||||
title: (
|
title: t("upload.modal.completed.share-ready"),
|
||||||
<Stack align="stretch" spacing={0}>
|
|
||||||
<Title order={4}>Share ready</Title>
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
children: <Body share={share} appUrl={appUrl} />,
|
children: <Body share={share} appUrl={appUrl} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
const link = `${appUrl}/s/${share.id}`;
|
||||||
|
|
||||||
const link = `${appUrl}/share/${share.id}`;
|
|
||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput
|
<CopyTextField link={link} />
|
||||||
readOnly
|
|
||||||
variant="filled"
|
|
||||||
value={link}
|
|
||||||
rightSection={
|
|
||||||
window.isSecureContext && (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => {
|
|
||||||
clipboard.copy(link);
|
|
||||||
toast.success("Your link was copied to the keyboard.");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TbCopy />
|
|
||||||
</ActionIcon>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
@@ -66,10 +43,10 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
|||||||
>
|
>
|
||||||
{/* If our share.expiration is timestamp 0, show a different message */}
|
{/* If our share.expiration is timestamp 0, show a different message */}
|
||||||
{moment(share.expiration).unix() === 0
|
{moment(share.expiration).unix() === 0
|
||||||
? "This share will never expire."
|
? t("upload.modal.completed.never-expires")
|
||||||
: `This share will expire on ${moment(share.expiration).format(
|
: t("upload.modal.completed.expires-on", {
|
||||||
"LLL"
|
expiration: moment(share.expiration).format("LLL"),
|
||||||
)}`}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -78,7 +55,7 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
|||||||
router.push("/upload");
|
router.push("/upload");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Done
|
<FormattedMessage id="common.button.done" />
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
Grid,
|
Grid,
|
||||||
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
@@ -13,15 +14,19 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TbAlertCircle } from "react-icons/tb";
|
import { TbAlertCircle } from "react-icons/tb";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
import useTranslate, {
|
||||||
|
translateOutsideContext,
|
||||||
|
} from "../../../hooks/useTranslate.hook";
|
||||||
import shareService from "../../../services/share.service";
|
import shareService from "../../../services/share.service";
|
||||||
|
import { FileUpload } from "../../../types/File.type";
|
||||||
import { CreateShare } from "../../../types/share.type";
|
import { CreateShare } from "../../../types/share.type";
|
||||||
import { getExpirationPreview } from "../../../utils/date.util";
|
import { getExpirationPreview } from "../../../utils/date.util";
|
||||||
|
|
||||||
@@ -34,13 +39,17 @@ const showCreateUploadModal = (
|
|||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
},
|
},
|
||||||
uploadCallback: (createShare: CreateShare) => void
|
files: FileUpload[],
|
||||||
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
|
||||||
) => {
|
) => {
|
||||||
|
const t = translateOutsideContext();
|
||||||
|
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: <Title order={4}>Share</Title>,
|
title: t("upload.modal.title"),
|
||||||
children: (
|
children: (
|
||||||
<CreateUploadModalBody
|
<CreateUploadModalBody
|
||||||
options={options}
|
options={options}
|
||||||
|
files={files}
|
||||||
uploadCallback={uploadCallback}
|
uploadCallback={uploadCallback}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -49,9 +58,11 @@ const showCreateUploadModal = (
|
|||||||
|
|
||||||
const CreateUploadModalBody = ({
|
const CreateUploadModalBody = ({
|
||||||
uploadCallback,
|
uploadCallback,
|
||||||
|
files,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
uploadCallback: (createShare: CreateShare) => void;
|
files: FileUpload[];
|
||||||
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
|
||||||
options: {
|
options: {
|
||||||
isUserSignedIn: boolean;
|
isUserSignedIn: boolean;
|
||||||
isReverseShare: boolean;
|
isReverseShare: boolean;
|
||||||
@@ -61,24 +72,29 @@ const CreateUploadModalBody = ({
|
|||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
|
const t = useTranslate();
|
||||||
|
|
||||||
|
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
|
||||||
|
.toString("base64")
|
||||||
|
.substr(10, 7);
|
||||||
|
|
||||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
link: yup
|
link: yup
|
||||||
.string()
|
.string()
|
||||||
.required()
|
.required(t("common.error.field-required"))
|
||||||
.min(3)
|
.min(3, t("common.error.too-short", { length: 3 }))
|
||||||
.max(50)
|
.max(50, t("common.error.too-long", { length: 50 }))
|
||||||
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
|
||||||
message: "Can only contain letters, numbers, underscores and hyphens",
|
message: t("upload.modal.link.error.invalid"),
|
||||||
}),
|
}),
|
||||||
password: yup.string().min(3).max(30),
|
password: yup.string().min(3).max(30),
|
||||||
maxViews: yup.number().min(1),
|
maxViews: yup.number().min(1),
|
||||||
});
|
});
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
link: "",
|
link: generatedLink,
|
||||||
recipients: [] as string[],
|
recipients: [] as string[],
|
||||||
password: undefined,
|
password: undefined,
|
||||||
maxViews: undefined,
|
maxViews: undefined,
|
||||||
@@ -96,61 +112,61 @@ const CreateUploadModalBody = ({
|
|||||||
withCloseButton
|
withCloseButton
|
||||||
onClose={() => setShowNotSignedInAlert(false)}
|
onClose={() => setShowNotSignedInAlert(false)}
|
||||||
icon={<TbAlertCircle size={16} />}
|
icon={<TbAlertCircle size={16} />}
|
||||||
title="You're not signed in"
|
title={t("upload.modal.not-signed-in")}
|
||||||
color="yellow"
|
color="yellow"
|
||||||
>
|
>
|
||||||
You will be unable to delete your share manually and view the visitor
|
<FormattedMessage id="upload.modal.not-signed-in-description" />
|
||||||
count.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit(async (values) => {
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||||
form.setFieldError("link", "This link is already in use");
|
form.setFieldError("link", t("upload.modal.link.error.taken"));
|
||||||
} else {
|
} else {
|
||||||
const expiration = form.values.never_expires
|
const expiration = form.values.never_expires
|
||||||
? "never"
|
? "never"
|
||||||
: form.values.expiration_num + form.values.expiration_unit;
|
: form.values.expiration_num + form.values.expiration_unit;
|
||||||
uploadCallback({
|
uploadCallback(
|
||||||
id: values.link,
|
{
|
||||||
expiration: expiration,
|
id: values.link,
|
||||||
recipients: values.recipients,
|
expiration: expiration,
|
||||||
description: values.description,
|
recipients: values.recipients,
|
||||||
security: {
|
description: values.description,
|
||||||
password: values.password,
|
security: {
|
||||||
maxViews: values.maxViews,
|
password: values.password,
|
||||||
|
maxViews: values.maxViews,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
files
|
||||||
|
);
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
<Group align="end">
|
||||||
<Col xs={9}>
|
<TextInput
|
||||||
<TextInput
|
style={{ flex: "1" }}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Link"
|
label={t("upload.modal.link.label")}
|
||||||
placeholder="myAwesomeShare"
|
placeholder="myAwesomeShare"
|
||||||
{...form.getInputProps("link")}
|
{...form.getInputProps("link")}
|
||||||
/>
|
/>
|
||||||
</Col>
|
<Button
|
||||||
<Col xs={3}>
|
style={{ flex: "0 0 auto" }}
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() =>
|
||||||
onClick={() =>
|
form.setFieldValue(
|
||||||
form.setFieldValue(
|
"link",
|
||||||
"link",
|
Buffer.from(Math.random().toString(), "utf8")
|
||||||
Buffer.from(Math.random().toString(), "utf8")
|
.toString("base64")
|
||||||
.toString("base64")
|
.substr(10, 7)
|
||||||
.substr(10, 7)
|
)
|
||||||
)
|
}
|
||||||
}
|
>
|
||||||
>
|
<FormattedMessage id="common.button.generate" />
|
||||||
Generate
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
italic
|
italic
|
||||||
@@ -159,8 +175,7 @@ const CreateUploadModalBody = ({
|
|||||||
color: theme.colors.gray[6],
|
color: theme.colors.gray[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{options.appUrl}/share/
|
{`${options.appUrl}/s/${form.values.link}`}
|
||||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
|
||||||
</Text>
|
</Text>
|
||||||
{!options.isReverseShare && (
|
{!options.isReverseShare && (
|
||||||
<>
|
<>
|
||||||
@@ -171,8 +186,7 @@ const CreateUploadModalBody = ({
|
|||||||
max={99999}
|
max={99999}
|
||||||
precision={0}
|
precision={0}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
label="Expiration"
|
label={t("upload.modal.expires.label")}
|
||||||
placeholder="n"
|
|
||||||
disabled={form.values.never_expires}
|
disabled={form.values.never_expires}
|
||||||
{...form.getInputProps("expiration_num")}
|
{...form.getInputProps("expiration_num")}
|
||||||
/>
|
/>
|
||||||
@@ -186,41 +200,51 @@ const CreateUploadModalBody = ({
|
|||||||
{
|
{
|
||||||
value: "-minutes",
|
value: "-minutes",
|
||||||
label:
|
label:
|
||||||
"Minute" +
|
form.values.expiration_num == 1
|
||||||
(form.values.expiration_num == 1 ? "" : "s"),
|
? t("upload.modal.expires.minute-singular")
|
||||||
|
: t("upload.modal.expires.minute-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-hours",
|
value: "-hours",
|
||||||
label:
|
label:
|
||||||
"Hour" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.hour-singular")
|
||||||
|
: t("upload.modal.expires.hour-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-days",
|
value: "-days",
|
||||||
label:
|
label:
|
||||||
"Day" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.day-singular")
|
||||||
|
: t("upload.modal.expires.day-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-weeks",
|
value: "-weeks",
|
||||||
label:
|
label:
|
||||||
"Week" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.week-singular")
|
||||||
|
: t("upload.modal.expires.week-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-months",
|
value: "-months",
|
||||||
label:
|
label:
|
||||||
"Month" +
|
form.values.expiration_num == 1
|
||||||
(form.values.expiration_num == 1 ? "" : "s"),
|
? t("upload.modal.expires.month-singular")
|
||||||
|
: t("upload.modal.expires.month-plural"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "-years",
|
value: "-years",
|
||||||
label:
|
label:
|
||||||
"Year" + (form.values.expiration_num == 1 ? "" : "s"),
|
form.values.expiration_num == 1
|
||||||
|
? t("upload.modal.expires.year-singular")
|
||||||
|
: t("upload.modal.expires.year-plural"),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Never Expires"
|
label={t("upload.modal.expires.never-long")}
|
||||||
{...form.getInputProps("never_expires")}
|
{...form.getInputProps("never_expires")}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -230,18 +254,28 @@ const CreateUploadModalBody = ({
|
|||||||
color: theme.colors.gray[6],
|
color: theme.colors.gray[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{getExpirationPreview("share", form)}
|
{getExpirationPreview(
|
||||||
|
{
|
||||||
|
neverExpires: t("upload.modal.completed.never-expires"),
|
||||||
|
expiresOn: t("upload.modal.completed.expires-on"),
|
||||||
|
},
|
||||||
|
form
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="description" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>Description</Accordion.Control>
|
<Accordion.Control>
|
||||||
|
<FormattedMessage id="upload.modal.accordion.description.title" />
|
||||||
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<Textarea
|
<Textarea
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="Note for the recepients"
|
placeholder={t(
|
||||||
|
"upload.modal.accordion.description.placeholder"
|
||||||
|
)}
|
||||||
{...form.getInputProps("description")}
|
{...form.getInputProps("description")}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -249,20 +283,22 @@ const CreateUploadModalBody = ({
|
|||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
{options.enableEmailRecepients && (
|
{options.enableEmailRecepients && (
|
||||||
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>Email recipients</Accordion.Control>
|
<Accordion.Control>
|
||||||
|
<FormattedMessage id="upload.modal.accordion.email.title" />
|
||||||
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
data={form.values.recipients}
|
data={form.values.recipients}
|
||||||
placeholder="Enter email recipients"
|
placeholder={t("upload.modal.accordion.email.placeholder")}
|
||||||
searchable
|
searchable
|
||||||
{...form.getInputProps("recipients")}
|
|
||||||
creatable
|
creatable
|
||||||
|
autoComplete="email-recipients"
|
||||||
getCreateLabel={(query) => `+ ${query}`}
|
getCreateLabel={(query) => `+ ${query}`}
|
||||||
onCreate={(query) => {
|
onCreate={(query) => {
|
||||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||||
form.setFieldError(
|
form.setFieldError(
|
||||||
"recipients",
|
"recipients",
|
||||||
"Invalid email address"
|
t("upload.modal.accordion.email.invalid-email")
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
form.setFieldError("recipients", null);
|
form.setFieldError("recipients", null);
|
||||||
@@ -273,34 +309,44 @@ const CreateUploadModalBody = ({
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...form.getInputProps("recipients")}
|
||||||
/>
|
/>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
|
||||||
<Accordion.Control>Security options</Accordion.Control>
|
<Accordion.Control>
|
||||||
|
<FormattedMessage id="upload.modal.accordion.security.title" />
|
||||||
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="No password"
|
placeholder={t(
|
||||||
label="Password protection"
|
"upload.modal.accordion.security.password.placeholder"
|
||||||
|
)}
|
||||||
|
label={t("upload.modal.accordion.security.password.label")}
|
||||||
|
autoComplete="off"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
min={1}
|
min={1}
|
||||||
type="number"
|
type="number"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder="No limit"
|
placeholder={t(
|
||||||
label="Maximal views"
|
"upload.modal.accordion.security.max-views.placeholder"
|
||||||
|
)}
|
||||||
|
label={t("upload.modal.accordion.security.max-views.label")}
|
||||||
{...form.getInputProps("maxViews")}
|
{...form.getInputProps("maxViews")}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Button type="submit">Share</Button>
|
<Button type="submit" data-autofocus>
|
||||||
|
<FormattedMessage id="common.button.share" />
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
39
frontend/src/hooks/useTranslate.hook.ts
Normal file
39
frontend/src/hooks/useTranslate.hook.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getCookie } from "cookies-next";
|
||||||
|
import { createIntl, createIntlCache, useIntl } from "react-intl";
|
||||||
|
import i18nUtil from "../utils/i18n.util";
|
||||||
|
|
||||||
|
const useTranslate = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
id: string,
|
||||||
|
values?: Parameters<typeof intl.formatMessage>[1],
|
||||||
|
opts?: Parameters<typeof intl.formatMessage>[2],
|
||||||
|
) => {
|
||||||
|
return intl.formatMessage({ id }, values, opts) as string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = createIntlCache();
|
||||||
|
|
||||||
|
export const translateOutsideContext = () => {
|
||||||
|
const locale =
|
||||||
|
getCookie("language")?.toString() ?? navigator.language.split("-")[0];
|
||||||
|
|
||||||
|
const intl = createIntl(
|
||||||
|
{
|
||||||
|
locale,
|
||||||
|
messages: i18nUtil.getLocaleByCode(locale)?.messages,
|
||||||
|
defaultLocale: "en",
|
||||||
|
},
|
||||||
|
cache,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
id: string,
|
||||||
|
values?: Parameters<typeof intl.formatMessage>[1],
|
||||||
|
opts?: Parameters<typeof intl.formatMessage>[2],
|
||||||
|
) => {
|
||||||
|
return intl.formatMessage({ id }, values, opts) as string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTranslate;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user