Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b088a5ef2a | ||
|
|
c502cd58db | ||
|
|
97e7d7190d | ||
|
|
38919003e9 | ||
|
|
f15a8dc277 | ||
|
|
92927b1373 | ||
|
|
6a4108ed61 | ||
|
|
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 |
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:
|
||||
release:
|
||||
@@ -10,15 +10,25 @@ jobs:
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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: Build the image
|
||||
run: |
|
||||
docker buildx build --push \
|
||||
--tag stonith404/pingvin-share:latest \
|
||||
--tag stonith404/pingvin-share:${{ github.ref_name }} \
|
||||
--platform linux/amd64,linux/arm64 .
|
||||
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- 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
|
||||
!/backend/prisma/.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
161
CHANGELOG.md
161
CHANGELOG.md
@@ -1,3 +1,164 @@
|
||||
## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4))
|
||||
* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939))
|
||||
|
||||
## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* permission changes of docker container brakes existing installations ([6a4108e](https://github.com/stonith404/pingvin-share/commit/6a4108ed6138e7297e66fd1e38450f23afe99aae))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
_Read this in another language: [Spanish](/docs/CONTRIBUTING.es.md), [English](/CONTRIBUTING.md), [Simplified Chinese](/docs/CONTRIBUTING.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,37 +1,40 @@
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:18-slim AS frontend-dependencies
|
||||
# Stage 1: Frontend dependencies
|
||||
FROM node:20-alpine AS frontend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: on frontend change
|
||||
FROM node:18-slim AS frontend-builder
|
||||
# Stage 2: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./frontend .
|
||||
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: on backend dependency change
|
||||
FROM node:18-slim AS backend-dependencies
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4:on backend change
|
||||
FROM node:18-slim AS backend-builder
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
# Stage 4: Build backend
|
||||
FROM node:20-alpine AS backend-builder
|
||||
WORKDIR /opt/app
|
||||
COPY ./backend .
|
||||
COPY --from=backend-dependencies /opt/app/node_modules ./node_modules
|
||||
RUN npx prisma generate
|
||||
RUN npm run build && npm prune --production
|
||||
RUN npm run build && npm prune --production
|
||||
|
||||
# Stage 5: Final image
|
||||
FROM node:18-slim AS runner
|
||||
FROM node:20-alpine AS runner
|
||||
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 nginx
|
||||
|
||||
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
WORKDIR /opt/app/frontend
|
||||
COPY --from=frontend-builder /opt/app/public ./public
|
||||
@@ -46,5 +49,12 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
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 && nginx && PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod
|
||||
73
README.md
73
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>
|
||||
|
||||
---
|
||||
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||
|
||||
---
|
||||
|
||||
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
|
||||
|
||||
## ✨ Features
|
||||
@@ -16,7 +22,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
## ⌨️ Setup
|
||||
|
||||
@@ -33,7 +39,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
|
||||
|
||||
Required tools:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 14
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 16
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
|
||||
|
||||
@@ -57,6 +63,8 @@ npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Nginx can be found in `/nginx/nginx.conf`.
|
||||
|
||||
The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Integrations
|
||||
@@ -88,16 +96,65 @@ docker compose up -d
|
||||
|
||||
#### Stand-alone
|
||||
|
||||
1. Remove the running app
|
||||
```
|
||||
pm2 delete pingvin-share-backend pingvin-share-frontend
|
||||
1. Stop the running app
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
|
||||
|
||||
### Custom branding
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
You can change the name and the logo of the app by visiting the admin configuration page.
|
||||
# Checkout the latest version
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# Start the backend
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
# Start the frontend
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can customize Pingvin Share by going to the configuration page in your admin dashboard.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
For installation specific configuration, you can use environment variables. The following variables are available:
|
||||
|
||||
##### Backend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | The port on which the backend listens. |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
|
||||
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
|
||||
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
|
||||
|
||||
##### Frontend
|
||||
|
||||
| Variable | Default Value | Description |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | The port on which the frontend listens. |
|
||||
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
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
|
||||
4685
backend/package-lock.json
generated
4685
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.18.2",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"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"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.3.9",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
"@nestjs/core": "^9.3.9",
|
||||
"@nestjs/jwt": "^10.0.2",
|
||||
"@nestjs/passport": "^9.0.3",
|
||||
"@nestjs/platform-express": "^9.3.9",
|
||||
"@nestjs/schedule": "^2.2.0",
|
||||
"@nestjs/swagger": "^6.2.1",
|
||||
"@nestjs/throttler": "^4.0.0",
|
||||
"@prisma/client": "^4.11.0",
|
||||
"@nestjs/common": "^10.1.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.2",
|
||||
"@nestjs/jwt": "^10.1.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.1.2",
|
||||
"@nestjs/schedule": "^3.0.1",
|
||||
"@nestjs/swagger": "^7.1.4",
|
||||
"@nestjs/throttler": "^4.2.1",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.2",
|
||||
@@ -33,48 +33,48 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nodemailer": "^6.9.1",
|
||||
"nodemailer": "^6.9.4",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^4.4.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.31.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.32.4",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.2.0",
|
||||
"@nestjs/schematics": "^9.0.4",
|
||||
"@nestjs/testing": "^9.3.9",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@nestjs/cli": "^10.1.10",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.1.2",
|
||||
"@types/archiver": "^5.3.2",
|
||||
"@types/clamscan": "^2.0.4",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.15.0",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-jwt": "^3.0.9",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
||||
"@typescript-eslint/parser": "^5.54.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-prettier": "^8.9.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.8.4",
|
||||
"prisma": "^4.11.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "^4.9.5",
|
||||
"ts-loader": "^9.4.4",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^5.1.6",
|
||||
"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 {
|
||||
provider = "sqlite"
|
||||
url = "file:../data/pingvin-share.db"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -131,15 +131,15 @@ model ShareSecurity {
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
value String
|
||||
description String
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
order Int
|
||||
|
||||
@@id([name, category])
|
||||
}
|
||||
|
||||
@@ -4,150 +4,118 @@ import * as crypto from "crypto";
|
||||
const configVariables: ConfigVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
general: {
|
||||
appName: {
|
||||
description: "Name of the application",
|
||||
type: "string",
|
||||
value: "Pingvin Share",
|
||||
defaultValue: "Pingvin Share",
|
||||
secret: false,
|
||||
},
|
||||
appUrl: {
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: "http://localhost:3000",
|
||||
|
||||
defaultValue: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
showHomePage: {
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
share: {
|
||||
allowRegistration: {
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
|
||||
defaultValue: "true",
|
||||
secret: false,
|
||||
},
|
||||
allowUnauthenticatedShares: {
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
description: "Maximum share size in bytes",
|
||||
type: "number",
|
||||
value: "1073741824",
|
||||
|
||||
defaultValue: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
zipCompressionLevel: {
|
||||
type: "number",
|
||||
defaultValue: "9",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
enableShareEmailRecipients: {
|
||||
description:
|
||||
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
defaultValue: "false",
|
||||
|
||||
secret: false,
|
||||
},
|
||||
shareRecipientsSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
defaultValue: "Files shared with you",
|
||||
},
|
||||
shareRecipientsMessage: {
|
||||
description:
|
||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||
defaultValue:
|
||||
"Hey!\n\n{creator} shared some files with you, view or download the files with this link: {shareUrl}\n\nThe share will expire {expires}.\n\nNote: {desc}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
reverseShareSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
type: "string",
|
||||
value: "Reverse share link used",
|
||||
defaultValue: "Reverse share link used",
|
||||
},
|
||||
reverseShareMessage: {
|
||||
description:
|
||||
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
|
||||
defaultValue:
|
||||
"Hey!\n\nA share was just created with your reverse share link: {shareUrl}\n\nShared securely with Pingvin Share 🐧",
|
||||
},
|
||||
resetPasswordSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
value: "Pingvin Share password reset",
|
||||
defaultValue: "Pingvin Share password reset",
|
||||
},
|
||||
resetPasswordMessage: {
|
||||
description:
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\n\nPingvin Share 🐧",
|
||||
},
|
||||
inviteSubject: {
|
||||
description:
|
||||
"Subject of the email which gets sent when an admin invites an user.",
|
||||
type: "string",
|
||||
value: "Pingvin Share invite",
|
||||
defaultValue: "Pingvin Share invite",
|
||||
},
|
||||
inviteMessage: {
|
||||
description:
|
||||
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
|
||||
defaultValue:
|
||||
"Hey!\n\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\n\nYour password is: {password}\n\nPingvin Share 🐧",
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
enabled: {
|
||||
description:
|
||||
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
host: {
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
defaultValue: "",
|
||||
},
|
||||
port: {
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "0",
|
||||
defaultValue: "0",
|
||||
},
|
||||
email: {
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
value: "",
|
||||
defaultValue: "",
|
||||
},
|
||||
username: {
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
defaultValue: "",
|
||||
},
|
||||
password: {
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
@@ -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() {
|
||||
for (const [category, configVariablesOfCategory] of Object.entries(
|
||||
|
||||
19
backend/src/app.controller.ts
Normal file
19
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
|
||||
@Controller("/")
|
||||
export class AppController {
|
||||
constructor(private prismaService: PrismaService) {}
|
||||
|
||||
@Get("health")
|
||||
async health(@Res({ passthrough: true }) res: Response) {
|
||||
try {
|
||||
await this.prismaService.config.findMany();
|
||||
return "OK";
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
return "ERROR";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ShareModule } from "./share/share.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +34,9 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
],
|
||||
controllers:[
|
||||
AppController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@@ -33,14 +33,14 @@ export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private authTotpService: AuthTotpService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post("signUp")
|
||||
@Throttle(10, 5 * 60)
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!this.config.get("share.allowRegistration"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
@@ -50,7 +50,7 @@ export class AuthController {
|
||||
response = this.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -61,7 +61,7 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authService.signIn(dto);
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AuthController {
|
||||
response = this.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,14 +81,14 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.authTotpService.signInTotp(dto);
|
||||
|
||||
response = this.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return new TokenDTO().from(result);
|
||||
@@ -113,12 +113,12 @@ export class AuthController {
|
||||
async updatePassword(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() dto: UpdatePasswordDTO
|
||||
@Body() dto: UpdatePasswordDTO,
|
||||
) {
|
||||
const result = await this.authService.updatePassword(
|
||||
user,
|
||||
dto.oldPassword,
|
||||
dto.password
|
||||
dto.password,
|
||||
);
|
||||
|
||||
response = this.addTokensToResponse(response, result.refreshToken);
|
||||
@@ -129,12 +129,12 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||
|
||||
const accessToken = await this.authService.refreshAccessToken(
|
||||
request.cookies.refresh_token
|
||||
request.cookies.refresh_token,
|
||||
);
|
||||
response = this.addTokensToResponse(response, undefined, accessToken);
|
||||
return new TokenDTO().from({ accessToken });
|
||||
@@ -143,7 +143,7 @@ export class AuthController {
|
||||
@Post("signOut")
|
||||
async signOut(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
await this.authService.signOut(request.cookies.access_token);
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
@@ -176,7 +176,7 @@ export class AuthController {
|
||||
private addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string
|
||||
accessToken?: string,
|
||||
) {
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
@@ -21,7 +21,7 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
@@ -39,7 +39,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -49,7 +49,7 @@ export class AuthService {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
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(
|
||||
user.id
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -158,7 +158,7 @@ export class AuthService {
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export class AuthService {
|
||||
|
||||
return this.createAccessToken(
|
||||
refreshTokenMetaData.user,
|
||||
refreshTokenMetaData.id
|
||||
refreshTokenMetaData.id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
private authService: AuthService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
@@ -70,7 +72,7 @@ export class AuthTotpService {
|
||||
await this.authService.createRefreshToken(user.id);
|
||||
const accessToken = await this.authService.createAccessToken(
|
||||
user,
|
||||
refreshTokenId
|
||||
refreshTokenId,
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
@@ -95,8 +97,8 @@ export class AuthTotpService {
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
"pingvin-share",
|
||||
secret
|
||||
this.config.get("general.appName"),
|
||||
secret,
|
||||
);
|
||||
|
||||
await this.prisma.user.update({
|
||||
|
||||
@@ -5,5 +5,5 @@ export const GetUser = createParamDecorator(
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,7 +8,10 @@ import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
config.get("internal.jwtSecret");
|
||||
super({
|
||||
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 fs from "fs";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
const clamscanConfig = {
|
||||
clamdscan: {
|
||||
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
||||
port: 3310,
|
||||
host: CLAMAV_HOST,
|
||||
port: CLAMAV_PORT,
|
||||
localFallback: false,
|
||||
},
|
||||
preference: "clamdscan",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ClamScanService {
|
||||
private readonly logger = new Logger(ClamScanService.name);
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
.init(clamscanConfig)
|
||||
.then((res) => {
|
||||
console.log("ClamAV is active");
|
||||
this.logger.log("ClamAV is active");
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
this.logger.log("ClamAV is not active");
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -39,14 +41,14 @@ export class ClamScanService {
|
||||
const infectedFiles = [];
|
||||
|
||||
const files = fs
|
||||
.readdirSync(`./data/uploads/shares/${shareId}`)
|
||||
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
|
||||
.filter((file) => file != "archive.zip");
|
||||
|
||||
for (const fileId of files) {
|
||||
const { isInfected } = await clamScan
|
||||
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
||||
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
|
||||
.catch(() => {
|
||||
console.log("ClamAV is not active");
|
||||
this.logger.log("ClamAV is not active");
|
||||
return { isInfected: false };
|
||||
});
|
||||
|
||||
@@ -78,8 +80,8 @@ export class ClamScanService {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||
this.logger.warn(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ConfigController {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private logoService: LogoService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -41,7 +41,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async getByCategory(@Param("category") category: string) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.getByCategory(category)
|
||||
await this.configService.getByCategory(category),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class ConfigController {
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.updateMany(data)
|
||||
await this.configService.updateMany(data),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export class ConfigController {
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new FileTypeValidator({ fileType: "image/png" })],
|
||||
})
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
return await this.logoService.create(file.buffer);
|
||||
}
|
||||
|
||||
@@ -11,20 +11,22 @@ import { PrismaService } from "src/prisma/prisma.service";
|
||||
export class ConfigService {
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
get(key: `${string}.${string}`): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => `${variable.category}.${variable.name}` == key
|
||||
(variable) => `${variable.category}.${variable.name}` == key,
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||
const value = configVariable.value ?? configVariable.defaultValue;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "boolean") return value == "true";
|
||||
if (configVariable.type == "string" || configVariable.type == "text")
|
||||
return configVariable.value;
|
||||
return value;
|
||||
}
|
||||
|
||||
async getByCategory(category: string) {
|
||||
@@ -35,8 +37,9 @@ export class ConfigService {
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -48,8 +51,9 @@ export class ConfigService {
|
||||
|
||||
return configVariables.map((variable) => {
|
||||
return {
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
...variable,
|
||||
key: `${variable.category}.${variable.name}`,
|
||||
value: variable.value ?? variable.defaultValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -77,13 +81,15 @@ export class ConfigService {
|
||||
if (!configVariable || configVariable.locked)
|
||||
throw new NotFoundException("Config variable not found");
|
||||
|
||||
if (
|
||||
if (value === "") {
|
||||
value = null;
|
||||
} else if (
|
||||
typeof value != configVariable.type &&
|
||||
typeof value == "string" &&
|
||||
configVariable.type != "text"
|
||||
) {
|
||||
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],
|
||||
},
|
||||
},
|
||||
data: { value: value.toString() },
|
||||
data: { value: value === null ? null : value.toString() },
|
||||
});
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
@@ -9,10 +9,10 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
secret: boolean;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
defaultValue: string;
|
||||
|
||||
@Expose()
|
||||
description: string;
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
obscured: boolean;
|
||||
@@ -25,7 +25,7 @@ export class AdminConfigDTO extends ConfigDTO {
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
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>[]) {
|
||||
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 {
|
||||
@IsString()
|
||||
key: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@ValidateIf((dto) => dto.value !== "")
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ const IMAGES_PATH = "../frontend/public/img";
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
async create(file: Buffer) {
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
|
||||
const resized = await sharp(file).resize(900).toBuffer();
|
||||
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary");
|
||||
this.createFavicon(file);
|
||||
this.createPWAIcons(file);
|
||||
}
|
||||
|
||||
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 * as moment from "moment";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
|
||||
getTransporter() {
|
||||
if (!this.config.get("smtp.enabled"))
|
||||
@@ -22,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,
|
||||
shareId: string,
|
||||
creator?: User
|
||||
creator?: User,
|
||||
description?: string,
|
||||
expiration?: Date,
|
||||
) {
|
||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.shareRecipientsSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.shareRecipientsSubject"),
|
||||
this.config
|
||||
.get("email.shareRecipientsMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{creator}", creator?.username ?? "Someone")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{desc}", description ?? "No description")
|
||||
.replaceAll(
|
||||
"{expires}",
|
||||
moment(expiration).unix() != 0
|
||||
? moment(expiration).fromNow()
|
||||
: "in: never",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
|
||||
const shareUrl = `${this.config.get("general.appUrl")}/s/${shareId}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.reverseShareSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.reverseShareSubject"),
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"general.appUrl"
|
||||
"general.appUrl",
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.resetPasswordSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.resetPasswordSubject"),
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
async sendInviteEmail(recipientEmail: string, password: string) {
|
||||
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("email.inviteSubject"),
|
||||
text: this.config
|
||||
await this.sendMail(
|
||||
recipientEmail,
|
||||
this.config.get("email.inviteSubject"),
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
"smtp.email",
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
text: "This is a test email",
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +28,18 @@ export class FileController {
|
||||
@Query() query: any,
|
||||
|
||||
@Body() body: string,
|
||||
@Param("shareId") shareId: string
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
const data = body.toString().split(",")[1];
|
||||
// Data can be empty if the file is empty
|
||||
const data = body.toString().split(",")[1] ?? "";
|
||||
|
||||
return await this.fileService.create(
|
||||
data,
|
||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||
{ id, name },
|
||||
shareId
|
||||
shareId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,12 +47,12 @@ export class FileController {
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const zip = this.fileService.getZip(shareId);
|
||||
res.set({
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||
"Content-Disposition": contentDisposition(`${shareId}.zip`),
|
||||
});
|
||||
|
||||
return new StreamableFile(zip);
|
||||
@@ -63,7 +64,7 @@ export class FileController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
@Param("fileId") fileId: string,
|
||||
@Query("download") download = "true"
|
||||
@Query("download") download = "true",
|
||||
) {
|
||||
const file = await this.fileService.get(shareId, fileId);
|
||||
|
||||
|
||||
@@ -11,20 +11,21 @@ import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
data: string,
|
||||
chunk: { index: number; total: number },
|
||||
file: { id?: string; name: string },
|
||||
shareId: string
|
||||
shareId: string,
|
||||
) {
|
||||
if (!file.id) file.id = crypto.randomUUID();
|
||||
|
||||
@@ -39,7 +40,7 @@ export class FileService {
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = fs.statSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
@@ -61,7 +62,7 @@ export class FileService {
|
||||
// Check if share size limit is exceeded
|
||||
const fileSizeSum = share.files.reduce(
|
||||
(n, { size }) => n + parseInt(size),
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
|
||||
@@ -73,23 +74,23 @@ export class FileService {
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
fs.renameSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||
`./data/uploads/shares/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = fs.statSync(
|
||||
`./data/uploads/shares/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
@@ -111,9 +112,7 @@ export class FileService {
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
const file = fs.createReadStream(
|
||||
`./data/uploads/shares/${shareId}/${fileId}`
|
||||
);
|
||||
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
return {
|
||||
metaData: {
|
||||
@@ -126,13 +125,13 @@ export class FileService {
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`./data/uploads/shares/${shareId}`, {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
getZip(shareId: string) {
|
||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
||||
return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
|
||||
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
constructor(
|
||||
private _shareService: ShareService,
|
||||
private _prisma: PrismaService
|
||||
private _prisma: PrismaService,
|
||||
) {
|
||||
super(_shareService, _prisma);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -52,7 +52,7 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"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 * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
private readonly logger = new Logger(JobsService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -34,8 +37,9 @@ export class JobsService {
|
||||
await this.fileService.deleteAllFiles(expiredShare.id);
|
||||
}
|
||||
|
||||
if (expiredShares.length > 0)
|
||||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
if (expiredShares.length > 0) {
|
||||
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -50,10 +54,11 @@ export class JobsService {
|
||||
await this.reverseShareService.remove(expiredReverseShare.id);
|
||||
}
|
||||
|
||||
if (expiredReverseShares.length > 0)
|
||||
console.log(
|
||||
`job: deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
if (expiredReverseShares.length > 0) {
|
||||
this.logger.log(
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
@@ -61,31 +66,31 @@ export class JobsService {
|
||||
let filesDeleted = 0;
|
||||
|
||||
const shareDirectories = fs
|
||||
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
||||
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name);
|
||||
|
||||
for (const shareDirectory of shareDirectories) {
|
||||
const temporaryFiles = fs
|
||||
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
||||
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
|
||||
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||
|
||||
for (const file of temporaryFiles) {
|
||||
const stats = fs.statSync(
|
||||
`./data/uploads/shares/${shareDirectory}/${file}`
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||
);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
.isBefore(moment());
|
||||
|
||||
if (isOlderThanOneDay) {
|
||||
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
||||
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${filesDeleted} temporary files`);
|
||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -107,7 +112,8 @@ export class JobsService {
|
||||
const deletedTokensCount =
|
||||
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
|
||||
|
||||
if (deletedTokensCount > 0)
|
||||
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
|
||||
if (deletedTokensCount > 0) {
|
||||
this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as bodyParser from "body-parser";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import * as fs from "fs";
|
||||
import { AppModule } from "./app.module";
|
||||
import { DATA_DIRECTORY } from "./constants";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
@@ -16,7 +17,9 @@ async function bootstrap() {
|
||||
app.use(cookieParser());
|
||||
app.set("trust proxy", true);
|
||||
|
||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||
await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
@@ -30,6 +33,6 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/swagger", app, document);
|
||||
}
|
||||
|
||||
await app.listen(8080);
|
||||
await app.listen(parseInt(process.env.PORT) || 8080);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { DATABASE_URL } from "../constants";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
@@ -7,7 +8,7 @@ export class PrismaService extends PrismaClient {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
url: "file:../data/pingvin-share.db?connection_limit=1",
|
||||
url: DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ export class ReverseShareDTO {
|
||||
@Expose()
|
||||
shareExpiration: Date;
|
||||
|
||||
@Expose()
|
||||
token: string;
|
||||
|
||||
from(partial: Partial<ReverseShareDTO>) {
|
||||
return plainToClass(ReverseShareDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
|
||||
return partial.map((part) =>
|
||||
plainToClass(ReverseShareTokenWithShares, part, {
|
||||
excludeExtraneousValues: true,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ReverseShareService } from "./reverseShare.service";
|
||||
export class ReverseShareController {
|
||||
constructor(
|
||||
private reverseShareService: ReverseShareService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -44,7 +44,7 @@ export class ReverseShareController {
|
||||
if (!isValid) throw new NotFoundException("Reverse share token not found");
|
||||
|
||||
return new ReverseShareDTO().from(
|
||||
await this.reverseShareService.getByToken(reverseShareToken)
|
||||
await this.reverseShareService.getByToken(reverseShareToken),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export class ReverseShareController {
|
||||
@UseGuards(JwtGuard)
|
||||
async getAllByUser(@GetUser() user: User) {
|
||||
return new ReverseShareTokenWithShares().fromList(
|
||||
await this.reverseShareService.getAllByUser(user.id)
|
||||
await this.reverseShareService.getAllByUser(user.id),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export class ReverseShareService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||
@@ -19,8 +19,8 @@ export class ReverseShareService {
|
||||
.add(
|
||||
data.shareExpiration.split("-")[0],
|
||||
data.shareExpiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
"-",
|
||||
)[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.toDate();
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ReverseShareService {
|
||||
|
||||
if (globalMaxShareSize < data.maxShareSize)
|
||||
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({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { Expose, plainToClass, Type } from "class-transformer";
|
||||
import { ShareDTO } from "./share.dto";
|
||||
import { FileDTO } from "../../file/dto/file.dto";
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
|
||||
export class MyShareDTO extends ShareDTO {
|
||||
export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
"files",
|
||||
"from",
|
||||
"fromList",
|
||||
] as const) {
|
||||
@Expose()
|
||||
views: number;
|
||||
|
||||
@@ -11,13 +17,17 @@ export class MyShareDTO extends ShareDTO {
|
||||
@Expose()
|
||||
recipients: string[];
|
||||
|
||||
@Expose()
|
||||
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
|
||||
files: Omit<FileDTO, "share" | "from">[];
|
||||
|
||||
from(partial: Partial<MyShareDTO>) {
|
||||
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<MyShareDTO>[]) {
|
||||
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>[]) {
|
||||
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 {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private reverseShareService: ReverseShareService
|
||||
private reverseShareService: ReverseShareService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export class CreateShareGuard extends JwtGuard {
|
||||
if (!reverseShareTokenId) return false;
|
||||
|
||||
const isReverseShareTokenValid = await this.reverseShareService.isValid(
|
||||
reverseShareTokenId
|
||||
reverseShareTokenId,
|
||||
);
|
||||
|
||||
return isReverseShareTokenValid;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ShareOwnerGuard implements CanActivate {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ShareService } from "src/share/share.service";
|
||||
export class ShareSecurityGuard implements CanActivate {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
@@ -22,7 +22,7 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -44,13 +44,13 @@ export class ShareSecurityGuard implements CanActivate {
|
||||
if (share.security?.password && !shareToken)
|
||||
throw new ForbiddenException(
|
||||
"This share is password protected",
|
||||
"share_password_required"
|
||||
"share_password_required",
|
||||
);
|
||||
|
||||
if (!(await this.shareService.verifyShareToken(shareId, shareToken)))
|
||||
throw new ForbiddenException(
|
||||
"Share token required",
|
||||
"share_token_required"
|
||||
"share_token_required",
|
||||
);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ShareTokenSecurity implements CanActivate {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class ShareController {
|
||||
@UseGuards(JwtGuard)
|
||||
async getMyShares(@GetUser() user: User) {
|
||||
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(
|
||||
@Body() body: CreateShareDTO,
|
||||
@Req() request: Request,
|
||||
@GetUser() user: User
|
||||
@GetUser() user: User,
|
||||
) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
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) {
|
||||
const { reverse_share_token } = request.cookies;
|
||||
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(
|
||||
@Param("id") id: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
@Body() body: SharePasswordDto
|
||||
@Body() body: SharePasswordDto,
|
||||
) {
|
||||
const token = await this.shareService.getShareToken(id, body.password);
|
||||
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 { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -27,7 +28,7 @@ export class ShareService {
|
||||
private config: ConfigService,
|
||||
private jwtService: JwtService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private clamScanService: ClamScanService
|
||||
private clamScanService: ClamScanService,
|
||||
) {}
|
||||
|
||||
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
|
||||
const reverseShare = await this.reverseShareService.getByToken(
|
||||
reverseShareToken
|
||||
reverseShareToken,
|
||||
);
|
||||
if (reverseShare) {
|
||||
expirationDate = reverseShare.shareExpiration;
|
||||
@@ -56,8 +57,8 @@ export class ShareService {
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
"-",
|
||||
)[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.toDate();
|
||||
} else {
|
||||
@@ -65,7 +66,7 @@ export class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -99,11 +100,11 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async createZip(shareId: string) {
|
||||
const path = `./data/uploads/shares/${shareId}`;
|
||||
const path = `${SHARE_DIRECTORY}/${shareId}`;
|
||||
|
||||
const files = await this.prisma.file.findMany({ where: { shareId } });
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
zlib: { level: this.config.get("share.zipCompressionLevel") },
|
||||
});
|
||||
const writeStream = fs.createWriteStream(`${path}/archive.zip`);
|
||||
|
||||
@@ -133,21 +134,23 @@ export class ShareService {
|
||||
|
||||
if (share.files.length == 0)
|
||||
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
|
||||
if (share.files.length > 1)
|
||||
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
|
||||
for (const recepient of share.recipients) {
|
||||
await this.emailService.sendMailToShareRecepients(
|
||||
recepient.email,
|
||||
// Send email for each recipient
|
||||
for (const recipient of share.recipients) {
|
||||
await this.emailService.sendMailToShareRecipients(
|
||||
recipient.email,
|
||||
share.id,
|
||||
share.creator
|
||||
share.creator,
|
||||
share.description,
|
||||
share.expiration,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,12 +161,12 @@ export class ShareService {
|
||||
) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
share.reverseShare.creator.email,
|
||||
share.id
|
||||
share.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any file is malicious with ClamAV
|
||||
this.clamScanService.checkAndRemove(share.id);
|
||||
void this.clamScanService.checkAndRemove(share.id);
|
||||
|
||||
if (share.reverseShare) {
|
||||
await this.prisma.reverseShare.update({
|
||||
@@ -172,7 +175,7 @@ export class ShareService {
|
||||
});
|
||||
}
|
||||
|
||||
return await this.prisma.share.update({
|
||||
return this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: true },
|
||||
});
|
||||
@@ -192,17 +195,15 @@ export class ShareService {
|
||||
orderBy: {
|
||||
expiration: "desc",
|
||||
},
|
||||
include: { recipients: true },
|
||||
include: { recipients: true, files: true },
|
||||
});
|
||||
|
||||
const sharesWithEmailRecipients = shares.map((share) => {
|
||||
return shares.map((share) => {
|
||||
return {
|
||||
...share,
|
||||
recipients: share.recipients.map((recipients) => recipients.email),
|
||||
};
|
||||
});
|
||||
|
||||
return sharesWithEmailRecipients;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<any> {
|
||||
@@ -222,7 +223,7 @@ export class ShareService {
|
||||
throw new NotFoundException("Share not found");
|
||||
return {
|
||||
...share,
|
||||
hasPassword: share.security?.password ? true : false,
|
||||
hasPassword: !!share.security?.password,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,13 +279,13 @@ export class ShareService {
|
||||
share?.security?.password &&
|
||||
!(await argon.verify(share.security.password, password))
|
||||
) {
|
||||
throw new ForbiddenException("Wrong password");
|
||||
throw new ForbiddenException("Wrong password", "wrong_password");
|
||||
}
|
||||
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"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",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ import { OmitType, PartialType } from "@nestjs/swagger";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
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>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class UserController {
|
||||
@UseGuards(JwtGuard)
|
||||
async updateCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Body() data: UpdateOwnUserDTO
|
||||
@Body() data: UpdateOwnUserDTO,
|
||||
) {
|
||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export class UserController {
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||
response.cookie("refresh_token", "", {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
@@ -11,7 +11,7 @@ import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
export class UserSevice {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async list() {
|
||||
@@ -29,7 +29,7 @@ export class UserSevice {
|
||||
if (!dto.password) {
|
||||
const randomPassword = crypto.randomUUID();
|
||||
hash = await argon.hash(randomPassword);
|
||||
this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||
await this.emailService.sendInviteEmail(dto.email, randomPassword);
|
||||
} else {
|
||||
hash = await argon.hash(dto.password);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
`A user with this ${duplicatedField} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export class UserSevice {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
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,11 +1,17 @@
|
||||
{
|
||||
"extends": ["eslint-config-next", "eslint:recommended", "prettier"],
|
||||
"extends": [
|
||||
"next",
|
||||
"eslint-config-next",
|
||||
"eslint:recommended",
|
||||
"prettier"
|
||||
],
|
||||
"plugins": ["react"],
|
||||
"rules": {
|
||||
"quotes": ["warn", "double", { "allowTemplateLiterals": true }],
|
||||
"react-hooks/exhaustive-deps": ["off"],
|
||||
"import/no-anonymous-default-export": ["off"],
|
||||
"no-unused-vars": ["warn"],
|
||||
"react/no-unescaped-entities": ["off"]
|
||||
"react/no-unescaped-entities": ["off"],
|
||||
"@next/next/no-img-element": ["off"]
|
||||
}
|
||||
}
|
||||
|
||||
1
frontend/.prettierignore
Normal file
1
frontend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/src/i18n/translations/*
|
||||
@@ -1,5 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const withPWA = require("next-pwa")({
|
||||
@@ -19,4 +18,7 @@ module.exports = withPWA({
|
||||
output: "standalone", env: {
|
||||
VERSION: version,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
apiURL: process.env.API_URL ?? 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
|
||||
2410
frontend/package-lock.json
generated
2410
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,53 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.18.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"src/**/*.ts*\""
|
||||
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^6.0.1",
|
||||
"@mantine/dropzone": "^6.0.1",
|
||||
"@mantine/form": "^6.0.1",
|
||||
"@mantine/hooks": "^6.0.1",
|
||||
"@mantine/modals": "^6.0.1",
|
||||
"@mantine/next": "^6.0.1",
|
||||
"@mantine/notifications": "^6.0.1",
|
||||
"axios": "^1.3.4",
|
||||
"cookies-next": "^2.1.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "^6.0.17",
|
||||
"@mantine/dropzone": "^6.0.17",
|
||||
"@mantine/form": "^6.0.17",
|
||||
"@mantine/hooks": "^6.0.17",
|
||||
"@mantine/modals": "^6.0.17",
|
||||
"@mantine/next": "^6.0.17",
|
||||
"@mantine/notifications": "^6.0.17",
|
||||
"axios": "^1.4.0",
|
||||
"cookies-next": "^2.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.13.1",
|
||||
"jose": "^4.14.4",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.2.4",
|
||||
"next": "^13.4.12",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
"p-limit": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.8.0",
|
||||
"sharp": "^0.31.3",
|
||||
"yup": "^1.0.2"
|
||||
"react-icons": "^4.10.1",
|
||||
"react-intl": "^6.4.4",
|
||||
"sharp": "^0.32.4",
|
||||
"yup": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"axios": "^1.3.4",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^13.2.4",
|
||||
"eslint-config-prettier": "^8.7.0",
|
||||
"prettier": "^2.8.4",
|
||||
"tar": "^6.1.13",
|
||||
"typescript": "^4.9.5"
|
||||
"@types/node": "20.4.5",
|
||||
"@types/react": "18.2.17",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"axios": "^1.4.0",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"eslint-config-prettier": "^8.9.0",
|
||||
"prettier": "^3.0.0",
|
||||
"tar": "^6.1.15",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 86 KiB |
@@ -1,6 +1,4 @@
|
||||
import Image from "next/image";
|
||||
|
||||
const Logo = ({ height, width }: { height: number; width: number }) => {
|
||||
return <Image src="/img/logo.png" alt="logo" height={height} width={width} />;
|
||||
return <img src="/img/logo.png" alt="logo" height={height} width={width} />;
|
||||
};
|
||||
export default Logo;
|
||||
|
||||
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 { useState } from "react";
|
||||
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 preferences = usePreferences();
|
||||
const [colorScheme, setColorScheme] = useState(
|
||||
preferences.get("colorScheme")
|
||||
userPreferences.get("colorScheme"),
|
||||
);
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const systemColorScheme = useColorScheme();
|
||||
@@ -23,10 +23,10 @@ const ThemeSwitcher = () => {
|
||||
<SegmentedControl
|
||||
value={colorScheme}
|
||||
onChange={(value) => {
|
||||
preferences.set("colorScheme", value);
|
||||
userPreferences.set("colorScheme", value);
|
||||
setColorScheme(value);
|
||||
toggleColorScheme(
|
||||
value == "system" ? systemColorScheme : (value as ColorScheme)
|
||||
value == "system" ? systemColorScheme : (value as ColorScheme),
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
@@ -34,7 +34,9 @@ const ThemeSwitcher = () => {
|
||||
label: (
|
||||
<Center>
|
||||
<TbMoon size={16} />
|
||||
<Box ml={10}>Dark</Box>
|
||||
<Box ml={10}>
|
||||
<FormattedMessage id="account.theme.dark" />
|
||||
</Box>
|
||||
</Center>
|
||||
),
|
||||
value: "dark",
|
||||
@@ -43,7 +45,9 @@ const ThemeSwitcher = () => {
|
||||
label: (
|
||||
<Center>
|
||||
<TbSun size={16} />
|
||||
<Box ml={10}>Light</Box>
|
||||
<Box ml={10}>
|
||||
<FormattedMessage id="account.theme.light" />
|
||||
</Box>
|
||||
</Center>
|
||||
),
|
||||
value: "light",
|
||||
@@ -52,7 +56,9 @@ const ThemeSwitcher = () => {
|
||||
label: (
|
||||
<Center>
|
||||
<TbDeviceLaptop size={16} />
|
||||
<Box ml={10}>System</Box>
|
||||
<Box ml={10}>
|
||||
<FormattedMessage id="account.theme.system" />
|
||||
</Box>
|
||||
</Center>
|
||||
),
|
||||
value: "system",
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../hooks/useTranslate.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
@@ -24,10 +26,11 @@ const showEnableTotpModal = (
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
password: string;
|
||||
}
|
||||
},
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Enable TOTP</Title>,
|
||||
title: t("account.modal.totp.title"),
|
||||
children: (
|
||||
<CreateEnableTotpModal options={options} refreshUser={refreshUser} />
|
||||
),
|
||||
@@ -46,6 +49,7 @@ const CreateEnableTotpModal = ({
|
||||
refreshUser: () => {};
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
code: yup
|
||||
@@ -67,14 +71,19 @@ const CreateEnableTotpModal = ({
|
||||
<div>
|
||||
<Center>
|
||||
<Stack>
|
||||
<Text>Step 1: Add your authenticator</Text>
|
||||
<Text>
|
||||
<FormattedMessage id="account.modal.totp.step1" />
|
||||
</Text>
|
||||
<Image src={options.qrCode} alt="QR Code" />
|
||||
|
||||
<Center>
|
||||
<span>OR</span>
|
||||
<span>
|
||||
{" "}
|
||||
<FormattedMessage id="common.text.or" />
|
||||
</span>
|
||||
</Center>
|
||||
|
||||
<Tooltip label="Click to copy">
|
||||
<Tooltip label={t("account.modal.totp.clickToCopy")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(options.secret);
|
||||
@@ -85,38 +94,42 @@ const CreateEnableTotpModal = ({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Center>
|
||||
<Text fz="xs">Enter manually</Text>
|
||||
<Text fz="xs"></Text>
|
||||
</Center>
|
||||
|
||||
<Text>Step 2: Validate your code</Text>
|
||||
<Text>
|
||||
<FormattedMessage id="account.modal.totp.step2" />
|
||||
</Text>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
authService
|
||||
.verifyTOTP(values.code, options.password)
|
||||
.then(() => {
|
||||
toast.success("Successfully enabled TOTP");
|
||||
toast.success(t("account.notify.totp.enable"));
|
||||
modals.closeAll();
|
||||
refreshUser();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Grid align="flex-end">
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Code"
|
||||
placeholder="******"
|
||||
{...form.getInputProps("code")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button variant="outline" type="submit">
|
||||
Verify
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
style={{ flex: "1" }}
|
||||
variant="filled"
|
||||
label={t("account.modal.totp.code")}
|
||||
placeholder="******"
|
||||
{...form.getInputProps("code")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ flex: "0 0 auto" }}
|
||||
variant="outline"
|
||||
type="submit"
|
||||
>
|
||||
<FormattedMessage id="account.modal.totp.verify" />
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Stack>
|
||||
</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 { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
|
||||
const showShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
shareId: string,
|
||||
appUrl: string
|
||||
appUrl: string,
|
||||
) => {
|
||||
const link = `${appUrl}/share/${shareId}`;
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${shareId}`;
|
||||
return modals.openModal({
|
||||
title: "Share link",
|
||||
title: t("account.shares.modal.share-link"),
|
||||
children: (
|
||||
<Stack align="stretch">
|
||||
<TextInput variant="filled" value={link} />
|
||||
|
||||
@@ -18,10 +18,13 @@ const AdminConfigInput = ({
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
stringValue: configVariable.value,
|
||||
textValue: configVariable.value,
|
||||
numberValue: parseInt(configVariable.value),
|
||||
booleanValue: configVariable.value == "true",
|
||||
stringValue: configVariable.value ?? configVariable.defaultValue,
|
||||
textValue: configVariable.value ?? configVariable.defaultValue,
|
||||
numberValue: parseInt(
|
||||
configVariable.value ?? configVariable.defaultValue,
|
||||
),
|
||||
booleanValue:
|
||||
(configVariable.value ?? configVariable.defaultValue) == "true",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,29 +38,38 @@ const AdminConfigInput = ({
|
||||
{configVariable.type == "string" &&
|
||||
(configVariable.obscured ? (
|
||||
<PasswordInput
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
{...form.getInputProps("stringValue")}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
{...form.getInputProps("stringValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{configVariable.type == "text" && (
|
||||
<Textarea
|
||||
style={{ width: "100%" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
autosize
|
||||
{...form.getInputProps("textValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput
|
||||
{...form.getInputProps("numberValue")}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(number) => onValueChange(configVariable, number)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useConfig from "../../../hooks/config.hook";
|
||||
import Logo from "../../Logo";
|
||||
|
||||
@@ -42,7 +43,7 @@ const ConfigurationHeader = ({
|
||||
</Link>
|
||||
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
|
||||
<Button variant="light" component={Link} href="/admin">
|
||||
Go back
|
||||
<FormattedMessage id="common.button.go-back" />
|
||||
</Button>
|
||||
</MediaQuery>
|
||||
</Group>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const categories = [
|
||||
{ name: "General", icon: <TbSquare /> },
|
||||
@@ -53,7 +54,7 @@ const ConfigurationNavBar = ({
|
||||
>
|
||||
<Navbar.Section>
|
||||
<Text size="xs" color="dimmed" mb="sm">
|
||||
Configuration
|
||||
<FormattedMessage id="admin.config.title" />
|
||||
</Text>
|
||||
<Stack spacing="xs">
|
||||
{categories.map((category) => (
|
||||
@@ -79,7 +80,11 @@ const ConfigurationNavBar = ({
|
||||
>
|
||||
{category.icon}
|
||||
</ThemeIcon>
|
||||
<Text size="sm">{category.name}</Text>
|
||||
<Text size="sm">
|
||||
<FormattedMessage
|
||||
id={`admin.config.category.${category.name.toLowerCase()}`}
|
||||
/>
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
@@ -87,7 +92,7 @@ const ConfigurationNavBar = ({
|
||||
</Navbar.Section>
|
||||
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
|
||||
<Button mt="xl" variant="light" component={Link} href="/admin">
|
||||
Go back
|
||||
<FormattedMessage id="common.button.go-back" />
|
||||
</Button>
|
||||
</MediaQuery>
|
||||
</Navbar>
|
||||
|
||||
@@ -2,6 +2,8 @@ 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,
|
||||
@@ -11,14 +13,16 @@ const LogoConfigInput = ({
|
||||
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}>Logo</Title>
|
||||
<Title order={6}>
|
||||
<FormattedMessage id="admin.config.general.logo" />
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" mb="xs">
|
||||
Change your logo by uploading a new image. The image must be a PNG and
|
||||
should have the format 1:1.
|
||||
<FormattedMessage id="admin.config.general.logo.description" />
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack></Stack>
|
||||
@@ -29,7 +33,7 @@ const LogoConfigInput = ({
|
||||
value={logo}
|
||||
onChange={(v) => setLogo(v)}
|
||||
accept=".png"
|
||||
placeholder="Pick image"
|
||||
placeholder={t("admin.config.general.logo.placeholder")}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useUser from "../../../hooks/user.hook";
|
||||
import configService from "../../../services/config.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
@@ -32,7 +33,7 @@ const TestEmailButton = ({
|
||||
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||
</Stack>
|
||||
),
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,7 +66,7 @@ const TestEmailButton = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send test email
|
||||
<FormattedMessage id="admin.config.smtp.button.test" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useModals } from "@mantine/modals";
|
||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||
import User from "../../../types/user.type";
|
||||
import showUpdateUserModal from "./showUpdateUserModal";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
const ManageUserTable = ({
|
||||
users,
|
||||
@@ -22,9 +23,15 @@ const ManageUserTable = ({
|
||||
<Table verticalSpacing="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Admin</th>
|
||||
<th>
|
||||
<FormattedMessage id="admin.users.table.username" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="admin.users.table.email" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="admin.users.table.admin" />
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -5,21 +5,22 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||
import userService from "../../../services/user.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const showCreateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
smtpEnabled: boolean,
|
||||
getUsers: () => void
|
||||
getUsers: () => void,
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Create user</Title>,
|
||||
title: "Create user",
|
||||
children: (
|
||||
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
|
||||
),
|
||||
@@ -35,6 +36,7 @@ const Body = ({
|
||||
smtpEnabled: boolean;
|
||||
getUsers: () => void;
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
@@ -45,10 +47,15 @@ const Body = ({
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
password: yup.string().min(8).optional(),
|
||||
})
|
||||
email: yup.string().email(t("common.error.invalid-email")),
|
||||
username: yup
|
||||
.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>
|
||||
<TextInput label="Username" {...form.getInputProps("username")} />
|
||||
<TextInput label="Email" {...form.getInputProps("email")} />
|
||||
<TextInput
|
||||
label={t("admin.users.modal.create.username")}
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("admin.users.modal.create.email")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
{smtpEnabled && (
|
||||
<Switch
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Set password manually"
|
||||
description="If not checked, the user will receive an email with a link to set their password."
|
||||
label={t("admin.users.modal.create.manual-password")}
|
||||
description={t(
|
||||
"admin.users.modal.create.manual-password.description",
|
||||
)}
|
||||
{...form.getInputProps("setPasswordManually", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{form.values.setPasswordManually ||
|
||||
(!smtpEnabled && (
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
))}
|
||||
{(form.values.setPasswordManually || !smtpEnabled) && (
|
||||
<PasswordInput
|
||||
label={t("admin.users.modal.create.password")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
styles={{
|
||||
body: {
|
||||
@@ -95,12 +109,14 @@ const Body = ({
|
||||
}}
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Admin privileges"
|
||||
description="If checked, the user will be able to access the admin panel."
|
||||
label={t("admin.users.modal.create.admin")}
|
||||
description={t("admin.users.modal.create.admin.description")}
|
||||
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Create</Button>
|
||||
<Button type="submit">
|
||||
<FormattedMessage id="common.button.create" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../../hooks/useTranslate.hook";
|
||||
import userService from "../../../services/user.service";
|
||||
import User from "../../../types/user.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
@@ -18,10 +21,11 @@ import toast from "../../../utils/toast.util";
|
||||
const showUpdateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
user: User,
|
||||
getUsers: () => void
|
||||
getUsers: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
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} />,
|
||||
});
|
||||
};
|
||||
@@ -35,6 +39,8 @@ const Body = ({
|
||||
user: User;
|
||||
getUsers: () => void;
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
@@ -43,9 +49,11 @@ const Body = ({
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
})
|
||||
email: yup.string().email(t("common.error.invalid-email")),
|
||||
username: yup
|
||||
.string()
|
||||
.min(3, t("common.error.too-short", { length: 3 })),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -55,8 +63,10 @@ const Body = ({
|
||||
},
|
||||
validate: yupResolver(
|
||||
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>
|
||||
<TextInput
|
||||
label="Username"
|
||||
label={t("admin.users.table.username")}
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput label="Email" {...accountForm.getInputProps("email")} />
|
||||
<TextInput
|
||||
label={t("admin.users.table.email")}
|
||||
{...accountForm.getInputProps("email")}
|
||||
/>
|
||||
<Switch
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Admin privileges"
|
||||
label={t("admin.users.edit.update.admin-privileges")}
|
||||
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
<Accordion>
|
||||
<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>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||
@@ -98,17 +113,21 @@ const Body = ({
|
||||
.update(user.id, {
|
||||
password: values.password,
|
||||
})
|
||||
.then(() => toast.success("Password changed successfully"))
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("admin.users.edit.update.notify.password.success"),
|
||||
),
|
||||
)
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
label={t("admin.users.edit.update.change-password.field")}
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Button variant="light" type="submit">
|
||||
Save new password
|
||||
<FormattedMessage id="admin.users.edit.update.change-password.button" />
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
@@ -117,7 +136,7 @@ const Body = ({
|
||||
</Accordion>
|
||||
<Group position="right">
|
||||
<Button type="submit" form="accountForm">
|
||||
Save
|
||||
<FormattedMessage id="common.button.save" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -15,8 +15,10 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { TbInfoCircle } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -24,14 +26,18 @@ import toast from "../../utils/toast.util";
|
||||
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const [showTotp, setShowTotp] = React.useState(false);
|
||||
const [loginToken, setLoginToken] = React.useState("");
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
emailOrUsername: yup.string().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({
|
||||
@@ -54,8 +60,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
icon: <TbInfoCircle />,
|
||||
color: "blue",
|
||||
radius: "md",
|
||||
title: "Two-factor authentication required",
|
||||
message: "Please enter your two-factor authentication code",
|
||||
title: t("signIn.notify.totp-required.title"),
|
||||
message: t("signIn.notify.totp-required.description"),
|
||||
});
|
||||
setLoginToken(response.data["loginToken"]);
|
||||
} else {
|
||||
@@ -88,13 +94,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Welcome back
|
||||
<FormattedMessage id="signin.title" />
|
||||
</Title>
|
||||
{config.get("share.allowRegistration") && (
|
||||
<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">
|
||||
{"Sign up"}
|
||||
<FormattedMessage id="signin.button.signup" />
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
@@ -107,20 +113,20 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
label="Email or username"
|
||||
placeholder="Your email or username"
|
||||
label={t("signin.input.email-or-username")}
|
||||
placeholder={t("signin.input.email-or-username.placeholder")}
|
||||
{...form.getInputProps("emailOrUsername")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
label={t("signin.input.password")}
|
||||
placeholder={t("signin.input.password.placeholder")}
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{showTotp && (
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Code"
|
||||
label={t("account.modal.totp.code")}
|
||||
placeholder="******"
|
||||
mt="md"
|
||||
{...form.getInputProps("totp")}
|
||||
@@ -129,12 +135,12 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
{config.get("smtp.enabled") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
Forgot password?
|
||||
<FormattedMessage id="resetPassword.title" />
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
<FormattedMessage id="signin.button.submit" />
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -20,12 +22,19 @@ import toast from "../../utils/toast.util";
|
||||
const SignUpForm = () => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
username: yup.string().min(3).required(),
|
||||
password: yup.string().min(8).required(),
|
||||
email: yup.string().email(t("common.error.invalid-email")).required(),
|
||||
username: yup
|
||||
.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({
|
||||
@@ -54,41 +63,41 @@ const SignUpForm = () => {
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Sign up
|
||||
<FormattedMessage id="signup.title" />
|
||||
</Title>
|
||||
{config.get("share.allowRegistration") && (
|
||||
<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">
|
||||
Sign in
|
||||
<FormattedMessage id="signup.button.signin" />
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
signUp(values.email, values.username, values.password)
|
||||
signUp(values.email, values.username, values.password),
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Your username"
|
||||
label={t("signup.input.username")}
|
||||
placeholder={t("signup.input.username.placeholder")}
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="Your email"
|
||||
label={t("signup.input.email")}
|
||||
placeholder={t("signup.input.email.placeholder")}
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
label={t("signin.input.password")}
|
||||
placeholder={t("signin.input.password.placeholder")}
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Let's get started
|
||||
<FormattedMessage id="signup.button.submit" />
|
||||
</Button>
|
||||
</form>
|
||||
</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 useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
const ActionAvatar = () => {
|
||||
const { user } = useUser();
|
||||
@@ -16,7 +17,7 @@ const ActionAvatar = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||
My account
|
||||
<FormattedMessage id="navbar.avatar.account" />
|
||||
</Menu.Item>
|
||||
{user!.isAdmin && (
|
||||
<Menu.Item
|
||||
@@ -24,7 +25,7 @@ const ActionAvatar = () => {
|
||||
href="/admin"
|
||||
icon={<TbSettings size={14} />}
|
||||
>
|
||||
Administration
|
||||
<FormattedMessage id="navbar.avatar.admin" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
@@ -34,7 +35,7 @@ const ActionAvatar = () => {
|
||||
}}
|
||||
icon={<TbDoorExit size={14} />}
|
||||
>
|
||||
Sign out
|
||||
<FormattedMessage id="navbar.avatar.signout" />
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import Logo from "../Logo";
|
||||
import ActionAvatar from "./ActionAvatar";
|
||||
import NavbarShareMenu from "./NavbarShareMenu";
|
||||
@@ -112,6 +113,7 @@ const Header = () => {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
const t = useTranslate();
|
||||
|
||||
const [opened, toggleOpened] = useDisclosure(false);
|
||||
|
||||
@@ -124,7 +126,7 @@ const Header = () => {
|
||||
const authenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
label: t("navbar.upload"),
|
||||
},
|
||||
{
|
||||
component: <NavbarShareMenu />,
|
||||
@@ -137,27 +139,27 @@ const Header = () => {
|
||||
let unauthenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
label: t("navbar.signin"),
|
||||
},
|
||||
];
|
||||
|
||||
if (config.get("share.allowUnauthenticatedShares")) {
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
label: t("navbar.upload"),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.get("general.showHomePage"))
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/",
|
||||
label: "Home",
|
||||
label: t("navbar.home"),
|
||||
});
|
||||
|
||||
if (config.get("share.allowRegistration"))
|
||||
unauthenticatedLinks.push({
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
label: t("navbar.signup"),
|
||||
});
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const NavbarShareMneu = () => {
|
||||
return (
|
||||
@@ -12,14 +13,14 @@ const NavbarShareMneu = () => {
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item component={Link} href="/account/shares" icon={<TbLink />}>
|
||||
My shares
|
||||
<FormattedMessage id="navbar.links.shares" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/account/reverseShares"
|
||||
icon={<TbArrowLoopLeft />}
|
||||
>
|
||||
Reverse shares
|
||||
<FormattedMessage id="navbar.links.reverse" />
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const DownloadAllButton = ({ shareId }: { shareId: string }) => {
|
||||
const [isZipReady, setIsZipReady] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const t = useTranslate();
|
||||
|
||||
const downloadAll = async () => {
|
||||
setIsLoading(true);
|
||||
await shareService
|
||||
@@ -39,13 +43,13 @@ const DownloadAllButton = ({ shareId }: { shareId: string }) => {
|
||||
loading={isLoading}
|
||||
onClick={() => {
|
||||
if (!isZipReady) {
|
||||
toast.error("The share is preparing. Try again in a few minutes.");
|
||||
toast.error(t("share.notify.download-all-preparing"));
|
||||
} else {
|
||||
downloadAll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download all
|
||||
<FormattedMessage id="share.button.download-all" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -8,9 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import mime from "mime-types";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
@@ -18,19 +17,52 @@ import { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
import TableSortIcon, { TableSort } from "../core/SortIcon";
|
||||
import showFilePreviewModal from "./modals/showFilePreviewModal";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
setShare,
|
||||
share,
|
||||
isLoading,
|
||||
}: {
|
||||
files?: FileMetaData[];
|
||||
setShare: Dispatch<SetStateAction<Share | undefined>>;
|
||||
share: Share;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const clipboard = useClipboard();
|
||||
const config = useConfig();
|
||||
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 link = `${config.get("general.appUrl")}/api/shares/${
|
||||
@@ -39,10 +71,10 @@ const FileList = ({
|
||||
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your file link was copied to the keyboard.");
|
||||
toast.success(t("common.notify.copied"));
|
||||
} else {
|
||||
modals.openModal({
|
||||
title: "File link",
|
||||
title: t("share.modal.file-link"),
|
||||
children: (
|
||||
<Stack align="stretch">
|
||||
<TextInput variant="filled" value={link} />
|
||||
@@ -52,55 +84,70 @@ const FileList = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(sortFiles, [sort]);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
<FormattedMessage id="share.table.name" />
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="name" />
|
||||
</Group>
|
||||
</th>
|
||||
<th>
|
||||
<Group spacing="xs">
|
||||
<FormattedMessage id="share.table.size" />
|
||||
<TableSortIcon sort={sort} setSort={setSort} property="size" />
|
||||
</Group>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: files!.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
showFilePreviewModal(share.id, file, modals)
|
||||
}
|
||||
size={25}
|
||||
>
|
||||
<TbEye />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={() => copyFileLink(file)}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/share/${share.id}/preview/${
|
||||
file.id
|
||||
}?type=${mime.contentType(file.name)}`}
|
||||
target="_blank"
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbEye />
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbDownload />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
@@ -1,24 +1,21 @@
|
||||
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { translateOutsideContext } from "../../../hooks/useTranslate.hook";
|
||||
import CopyTextField from "../../upload/CopyTextField";
|
||||
|
||||
const showCompletedReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
link: string,
|
||||
getReverseShares: () => void
|
||||
getReverseShares: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Reverse share link</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: t("account.reverseShares.modal.reverse-share-link"),
|
||||
children: <Body link={link} getReverseShares={getReverseShares} />,
|
||||
});
|
||||
};
|
||||
@@ -30,28 +27,11 @@ const Body = ({
|
||||
link: string;
|
||||
getReverseShares: () => void;
|
||||
}) => {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const modals = useModals();
|
||||
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<TextInput
|
||||
readOnly
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
window.isSecureContext && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<CopyTextField link={link} />
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -59,7 +39,7 @@ const Body = ({
|
||||
getReverseShares();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
<FormattedMessage id="common.button.done" />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
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 { getExpirationPreview } from "../../../utils/date.util";
|
||||
import toast from "../../../utils/toast.util";
|
||||
@@ -22,10 +25,11 @@ import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
||||
const showCreateReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
showSendEmailNotificationOption: boolean,
|
||||
getReverseShares: () => void
|
||||
getReverseShares: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Create reverse share</Title>,
|
||||
title: t("account.reverseShares.modal.title"),
|
||||
children: (
|
||||
<Body
|
||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||
@@ -43,6 +47,7 @@ const Body = ({
|
||||
showSendEmailNotificationOption: boolean;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -62,7 +67,7 @@ const Body = ({
|
||||
values.expiration_num + values.expiration_unit,
|
||||
values.maxShareSize,
|
||||
values.maxUseCount,
|
||||
values.sendEmailNotification
|
||||
values.sendEmailNotification,
|
||||
)
|
||||
.then(({ link }) => {
|
||||
modals.closeAll();
|
||||
@@ -80,7 +85,7 @@ const Body = ({
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Share expiration"
|
||||
label={t("account.reverseShares.modal.expiration.label")}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
</Col>
|
||||
@@ -92,27 +97,44 @@ const Body = ({
|
||||
{
|
||||
value: "-minutes",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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],
|
||||
})}
|
||||
>
|
||||
{getExpirationPreview("reverse share", form)}
|
||||
{getExpirationPreview(
|
||||
{
|
||||
expiresOn: t("account.reverseShare.expires-on"),
|
||||
neverExpires: t("account.reverseShare.never-expires"),
|
||||
},
|
||||
form,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<FileSizeInput
|
||||
label="Max share size"
|
||||
label={t("account.reverseShares.modal.max-size.label")}
|
||||
value={form.values.maxShareSize}
|
||||
onChange={(number) => form.setFieldValue("maxShareSize", number)}
|
||||
/>
|
||||
@@ -139,16 +167,18 @@ const Body = ({
|
||||
max={1000}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Max use count"
|
||||
description="The maximum number of times this reverse share link can be used"
|
||||
label={t("account.reverseShares.modal.max-use.label")}
|
||||
description={t("account.reverseShares.modal.max-use.description")}
|
||||
{...form.getInputProps("maxUseCount")}
|
||||
/>
|
||||
{showSendEmailNotificationOption && (
|
||||
<Switch
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Send email notification"
|
||||
description="Send an email notification when a share is created with this reverse share link"
|
||||
label={t("account.reverseShares.modal.send-email")}
|
||||
description={t(
|
||||
"account.reverseShares.modal.send-email.description",
|
||||
)}
|
||||
{...form.getInputProps("sendEmailNotification", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
@@ -156,7 +186,7 @@ const Body = ({
|
||||
)}
|
||||
|
||||
<Button mt="md" type="submit">
|
||||
Create
|
||||
<FormattedMessage id="common.button.create" />
|
||||
</Button>
|
||||
</Stack>
|
||||
</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 { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../hooks/useTranslate.hook";
|
||||
|
||||
const showEnterPasswordModal = (
|
||||
modals: ModalsContextProps,
|
||||
submitCallback: any
|
||||
submitCallback: (password: string) => Promise<void>,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<>
|
||||
<Title order={4}>Password required</Title>
|
||||
<Text size="sm">
|
||||
This access this share please enter the password for the share.
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
title: t("share.modal.password.title"),
|
||||
children: <Body submitCallback={submitCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ submitCallback }: { submitCallback: any }) => {
|
||||
const Body = ({
|
||||
submitCallback,
|
||||
}: {
|
||||
submitCallback: (password: string) => Promise<void>;
|
||||
}) => {
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordWrong, setPasswordWrong] = useState(false);
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="Password"
|
||||
error={passwordWrong && "Wrong password"}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
submitCallback(password)
|
||||
.then((res: any) => res)
|
||||
.catch((e: any) => {
|
||||
const error = e.response.data.message;
|
||||
if (error == "Wrong password") {
|
||||
setPasswordWrong(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
<Stack align="stretch">
|
||||
<Text size="sm">
|
||||
<FormattedMessage id="share.modal.password.description" />
|
||||
</Text>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCallback(password);
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder={t("share.modal.password")}
|
||||
error={passwordWrong && t("share.modal.error.invalid-password")}
|
||||
onFocus={() => setPasswordWrong(false)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
value={password}
|
||||
/>
|
||||
<Button type="submit">
|
||||
<FormattedMessage id="common.button.submit" />
|
||||
</Button>
|
||||
</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 { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const showErrorModal = (
|
||||
modals: ModalsContextProps,
|
||||
title: string,
|
||||
text: string
|
||||
text: string,
|
||||
) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>{title}</Title>,
|
||||
title: title,
|
||||
|
||||
children: <Body text={text} />,
|
||||
});
|
||||
@@ -31,7 +32,7 @@ const Body = ({ text }: { text: string }) => {
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
<FormattedMessage id="common.button.go-back" />
|
||||
</Button>
|
||||
</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 { 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 useConfig from "../../hooks/config.hook";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
@@ -34,15 +35,13 @@ const useStyles = createStyles((theme) => ({
|
||||
const Dropzone = ({
|
||||
isUploading,
|
||||
maxShareSize,
|
||||
files,
|
||||
setFiles,
|
||||
showCreateUploadModalCallback,
|
||||
}: {
|
||||
isUploading: boolean;
|
||||
maxShareSize: number;
|
||||
files: FileUpload[];
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const t = useTranslate();
|
||||
|
||||
const { classes } = useStyles();
|
||||
const openRef = useRef<() => void>();
|
||||
@@ -54,24 +53,21 @@ const Dropzone = ({
|
||||
}}
|
||||
disabled={isUploading}
|
||||
openRef={openRef as ForwardedRef<() => void>}
|
||||
onDrop={(newFiles: FileUpload[]) => {
|
||||
const fileSizeSum = [...newFiles, ...files].reduce(
|
||||
(n, { size }) => n + size,
|
||||
0
|
||||
);
|
||||
onDrop={(files: FileUpload[]) => {
|
||||
const fileSizeSum = files.reduce((n, { size }) => n + size, 0);
|
||||
|
||||
if (fileSizeSum > maxShareSize) {
|
||||
toast.error(
|
||||
`Your files exceed the maximum share size of ${byteToHumanSizeString(
|
||||
maxShareSize
|
||||
)}.`
|
||||
t("upload.dropzone.notify.file-too-big", {
|
||||
maxSize: byteToHumanSizeString(maxShareSize),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
newFiles = newFiles.map((newFile) => {
|
||||
files = files.map((newFile) => {
|
||||
newFile.uploadingProgress = 0;
|
||||
return newFile;
|
||||
});
|
||||
setFiles([...newFiles, ...files]);
|
||||
showCreateUploadModalCallback(files);
|
||||
}
|
||||
}}
|
||||
className={classes.dropzone}
|
||||
@@ -82,12 +78,13 @@ const Dropzone = ({
|
||||
<TbCloudUpload size={50} />
|
||||
</Group>
|
||||
<Text align="center" weight={700} size="lg" mt="xl">
|
||||
Upload files
|
||||
<FormattedMessage id="upload.dropzone.title" />
|
||||
</Text>
|
||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||
Drag'n'drop files here to start your share. We can accept
|
||||
only files that are less than {byteToHumanSizeString(maxShareSize)}{" "}
|
||||
in total.
|
||||
<FormattedMessage
|
||||
id="upload.dropzone.description"
|
||||
values={{ maxSize: byteToHumanSizeString(maxShareSize) }}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</MantineDropzone>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TbTrash } from "react-icons/tb";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
@@ -41,8 +42,12 @@ const FileList = ({
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>
|
||||
<FormattedMessage id="upload.filelist.name" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="upload.filelist.size" />
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,63 +1,40 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCopy } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../../hooks/useTranslate.hook";
|
||||
import { Share } from "../../../types/share.type";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import CopyTextField from "../CopyTextField";
|
||||
|
||||
const showCompletedUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: Share,
|
||||
appUrl: string
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: (
|
||||
<Stack align="stretch" spacing={0}>
|
||||
<Title order={4}>Share ready</Title>
|
||||
</Stack>
|
||||
),
|
||||
title: t("upload.modal.completed.share-ready"),
|
||||
children: <Body share={share} appUrl={appUrl} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
|
||||
const link = `${appUrl}/s/${share.id}`;
|
||||
|
||||
const link = `${appUrl}/share/${share.id}`;
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<TextInput
|
||||
readOnly
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
window.isSecureContext && (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
clipboard.copy(link);
|
||||
toast.success("Your link was copied to the keyboard.");
|
||||
}}
|
||||
>
|
||||
<TbCopy />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<CopyTextField link={link} />
|
||||
<Text
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
@@ -66,10 +43,10 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||
>
|
||||
{/* If our share.expiration is timestamp 0, show a different message */}
|
||||
{moment(share.expiration).unix() === 0
|
||||
? "This share will never expire."
|
||||
: `This share will expire on ${moment(share.expiration).format(
|
||||
"LLL"
|
||||
)}`}
|
||||
? t("upload.modal.completed.never-expires")
|
||||
: t("upload.modal.completed.expires-on", {
|
||||
expiration: moment(share.expiration).format("LLL"),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
@@ -78,7 +55,7 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||
router.push("/upload");
|
||||
}}
|
||||
>
|
||||
Done
|
||||
<FormattedMessage id="common.button.done" />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Checkbox,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
@@ -13,15 +14,19 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import { useState } from "react";
|
||||
import { TbAlertCircle } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate, {
|
||||
translateOutsideContext,
|
||||
} from "../../../hooks/useTranslate.hook";
|
||||
import shareService from "../../../services/share.service";
|
||||
import { FileUpload } from "../../../types/File.type";
|
||||
import { CreateShare } from "../../../types/share.type";
|
||||
import { getExpirationPreview } from "../../../utils/date.util";
|
||||
|
||||
@@ -34,13 +39,17 @@ const showCreateUploadModal = (
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
},
|
||||
uploadCallback: (createShare: CreateShare) => void
|
||||
files: FileUpload[],
|
||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
title: t("upload.modal.title"),
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
options={options}
|
||||
files={files}
|
||||
uploadCallback={uploadCallback}
|
||||
/>
|
||||
),
|
||||
@@ -49,9 +58,11 @@ const showCreateUploadModal = (
|
||||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
files,
|
||||
options,
|
||||
}: {
|
||||
uploadCallback: (createShare: CreateShare) => void;
|
||||
files: FileUpload[];
|
||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
isReverseShare: boolean;
|
||||
@@ -61,24 +72,29 @@ const CreateUploadModalBody = ({
|
||||
};
|
||||
}) => {
|
||||
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 validationSchema = yup.object().shape({
|
||||
link: yup
|
||||
.string()
|
||||
.required()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.required(t("common.error.field-required"))
|
||||
.min(3, t("common.error.too-short", { length: 3 }))
|
||||
.max(50, t("common.error.too-long", { length: 50 }))
|
||||
.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),
|
||||
maxViews: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
link: generatedLink,
|
||||
recipients: [] as string[],
|
||||
password: undefined,
|
||||
maxViews: undefined,
|
||||
@@ -96,61 +112,61 @@ const CreateUploadModalBody = ({
|
||||
withCloseButton
|
||||
onClose={() => setShowNotSignedInAlert(false)}
|
||||
icon={<TbAlertCircle size={16} />}
|
||||
title="You're not signed in"
|
||||
title={t("upload.modal.not-signed-in")}
|
||||
color="yellow"
|
||||
>
|
||||
You will be unable to delete your share manually and view the visitor
|
||||
count.
|
||||
<FormattedMessage id="upload.modal.not-signed-in-description" />
|
||||
</Alert>
|
||||
)}
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
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 {
|
||||
const expiration = form.values.never_expires
|
||||
? "never"
|
||||
: form.values.expiration_num + form.values.expiration_unit;
|
||||
uploadCallback({
|
||||
id: values.link,
|
||||
expiration: expiration,
|
||||
recipients: values.recipients,
|
||||
description: values.description,
|
||||
security: {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
uploadCallback(
|
||||
{
|
||||
id: values.link,
|
||||
expiration: expiration,
|
||||
recipients: values.recipients,
|
||||
description: values.description,
|
||||
security: {
|
||||
password: values.password,
|
||||
maxViews: values.maxViews,
|
||||
},
|
||||
},
|
||||
});
|
||||
files
|
||||
);
|
||||
modals.closeAll();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Stack align="stretch">
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Col xs={9}>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label="Link"
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
style={{ flex: "1" }}
|
||||
variant="filled"
|
||||
label={t("upload.modal.link.label")}
|
||||
placeholder="myAwesomeShare"
|
||||
{...form.getInputProps("link")}
|
||||
/>
|
||||
<Button
|
||||
style={{ flex: "0 0 auto" }}
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
form.setFieldValue(
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormattedMessage id="common.button.generate" />
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Text
|
||||
italic
|
||||
@@ -159,8 +175,7 @@ const CreateUploadModalBody = ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
{options.appUrl}/share/
|
||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||
{`${options.appUrl}/s/${form.values.link}`}
|
||||
</Text>
|
||||
{!options.isReverseShare && (
|
||||
<>
|
||||
@@ -171,8 +186,7 @@ const CreateUploadModalBody = ({
|
||||
max={99999}
|
||||
precision={0}
|
||||
variant="filled"
|
||||
label="Expiration"
|
||||
placeholder="n"
|
||||
label={t("upload.modal.expires.label")}
|
||||
disabled={form.values.never_expires}
|
||||
{...form.getInputProps("expiration_num")}
|
||||
/>
|
||||
@@ -186,41 +200,51 @@ const CreateUploadModalBody = ({
|
||||
{
|
||||
value: "-minutes",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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:
|
||||
"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>
|
||||
</Grid>
|
||||
<Checkbox
|
||||
label="Never Expires"
|
||||
label={t("upload.modal.expires.never-long")}
|
||||
{...form.getInputProps("never_expires")}
|
||||
/>
|
||||
<Text
|
||||
@@ -230,18 +254,28 @@ const CreateUploadModalBody = ({
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
<Accordion>
|
||||
<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>
|
||||
<Stack align="stretch">
|
||||
<Textarea
|
||||
variant="filled"
|
||||
placeholder="Note for the recepients"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.description.placeholder"
|
||||
)}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -249,20 +283,22 @@ const CreateUploadModalBody = ({
|
||||
</Accordion.Item>
|
||||
{options.enableEmailRecepients && (
|
||||
<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>
|
||||
<MultiSelect
|
||||
data={form.values.recipients}
|
||||
placeholder="Enter email recipients"
|
||||
placeholder={t("upload.modal.accordion.email.placeholder")}
|
||||
searchable
|
||||
{...form.getInputProps("recipients")}
|
||||
creatable
|
||||
autoComplete="email-recipients"
|
||||
getCreateLabel={(query) => `+ ${query}`}
|
||||
onCreate={(query) => {
|
||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||
form.setFieldError(
|
||||
"recipients",
|
||||
"Invalid email address"
|
||||
t("upload.modal.accordion.email.invalid-email")
|
||||
);
|
||||
} else {
|
||||
form.setFieldError("recipients", null);
|
||||
@@ -273,34 +309,44 @@ const CreateUploadModalBody = ({
|
||||
return query;
|
||||
}
|
||||
}}
|
||||
{...form.getInputProps("recipients")}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.password.placeholder"
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.password.label")}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.max-views.placeholder"
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.max-views.label")}
|
||||
{...form.getInputProps("maxViews")}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
<Button type="submit" data-autofocus>
|
||||
<FormattedMessage id="common.button.share" />
|
||||
</Button>
|
||||
</Stack>
|
||||
</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