Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4ff0f0f0 | ||
|
|
3ea52a24ef | ||
|
|
f179189b59 | ||
|
|
bc333f768f | ||
|
|
26c98e2b41 | ||
|
|
4b7732838d | ||
|
|
021b9ac5d5 | ||
|
|
5f94c7295a | ||
|
|
d9a9523c9a | ||
|
|
384d2343d5 | ||
|
|
7a387d86d6 | ||
|
|
330eef51e4 | ||
|
|
2e1a2b60c4 | ||
|
|
9896ca0e8c | ||
|
|
fd44f42f28 | ||
|
|
966ce261cb | ||
|
|
5503e7a54f | ||
|
|
b49ec93c54 | ||
|
|
e6584322fa | ||
|
|
1138cd02b0 | ||
|
|
1ba8d0cbd1 | ||
|
|
98380e2d48 | ||
|
|
e377ed10e1 | ||
|
|
acc35f4717 | ||
|
|
33742a043d | ||
|
|
5cee9cbbb9 | ||
|
|
e0fbbeca3c | ||
|
|
bbfc9d6f14 | ||
|
|
46b6e56c06 | ||
|
|
05f6582739 | ||
|
|
119b1ec840 | ||
|
|
e89e313712 | ||
|
|
c2ff658182 | ||
|
|
02cd98fa9c | ||
|
|
d327bc355c | ||
|
|
8ae631a626 | ||
|
|
1d8dc8fe5b | ||
|
|
688ae6c86e | ||
|
|
21809843cd | ||
|
|
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 |
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 +0,0 @@
|
||||
/backend/src/constants.ts
|
||||
132
CHANGELOG.md
132
CHANGELOG.md
@@ -1,3 +1,135 @@
|
||||
## [0.21.1](https://github.com/stonith404/pingvin-share/compare/v0.21.0...v0.21.1) (2023-12-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** add oidc username claim ([#357](https://github.com/stonith404/pingvin-share/issues/357)) ([3ea52a2](https://github.com/stonith404/pingvin-share/commit/3ea52a24ef7c3b6845bc13382616ea0c8d784585))
|
||||
|
||||
## [0.21.0](https://github.com/stonith404/pingvin-share/compare/v0.20.3...v0.21.0) (2023-12-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **oauth:** limited discord server sign-in ([#346](https://github.com/stonith404/pingvin-share/issues/346)) ([5f94c72](https://github.com/stonith404/pingvin-share/commit/5f94c7295ab8594ed2ed615628214e869a02da2d))
|
||||
|
||||
## [0.20.3](https://github.com/stonith404/pingvin-share/compare/v0.20.2...v0.20.3) (2023-11-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* max expiration gets ignored if expiration is set to "never" ([330eef5](https://github.com/stonith404/pingvin-share/commit/330eef51e4f3f3fb29833bc9337e705553340aaa))
|
||||
|
||||
## [0.20.2](https://github.com/stonith404/pingvin-share/compare/v0.20.1...v0.20.2) (2023-11-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oauth:** github and discord login error ([#323](https://github.com/stonith404/pingvin-share/issues/323)) ([fd44f42](https://github.com/stonith404/pingvin-share/commit/fd44f42f28c0fa2091876b138f170202d9fde04e)), closes [#322](https://github.com/stonith404/pingvin-share/issues/322) [#302](https://github.com/stonith404/pingvin-share/issues/302)
|
||||
* reverse shares couldn't be created unauthenticated ([966ce26](https://github.com/stonith404/pingvin-share/commit/966ce261cb4ad99efaadef5c36564fdfaed0d5c4))
|
||||
|
||||
## [0.20.1](https://github.com/stonith404/pingvin-share/compare/v0.20.0...v0.20.1) (2023-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* share information text color in light mode ([1138cd0](https://github.com/stonith404/pingvin-share/commit/1138cd02b0b6ac1d71c4dbc2808110c672237190))
|
||||
|
||||
## [0.20.0](https://github.com/stonith404/pingvin-share/compare/v0.19.2...v0.20.0) (2023-11-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to add and delete files of existing share ([#306](https://github.com/stonith404/pingvin-share/issues/306)) ([98380e2](https://github.com/stonith404/pingvin-share/commit/98380e2d48cc8ffa831d9b69cf5c0e8a40e28862))
|
||||
|
||||
## [0.19.2](https://github.com/stonith404/pingvin-share/compare/v0.19.1...v0.19.2) (2023-11-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to limit the max expiration of a share ([bbfc9d6](https://github.com/stonith404/pingvin-share/commit/bbfc9d6f147eea404f011c3af9d7dc7655c3d21d))
|
||||
* change totp issuer to display logo in 2FAS app ([e0fbbec](https://github.com/stonith404/pingvin-share/commit/e0fbbeca3c1a858838b20aeead52694772b7d871))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* jwt secret changes on application restart ([33742a0](https://github.com/stonith404/pingvin-share/commit/33742a043d6549783984ae7e8a3c30f0fe3917de))
|
||||
* wrong validation of setting max share expiration to `0` ([acc35f4](https://github.com/stonith404/pingvin-share/commit/acc35f47178e230f50ce54d6f1ad5370caa3382d))
|
||||
|
||||
## [0.19.1](https://github.com/stonith404/pingvin-share/compare/v0.19.0...v0.19.1) (2023-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **oauth:** fix wrong redirectUri in oidc after change appUrl ([#296](https://github.com/stonith404/pingvin-share/issues/296)) ([119b1ec](https://github.com/stonith404/pingvin-share/commit/119b1ec840ad7f4e1c7c4bb476bf1eeed91d9a1a))
|
||||
|
||||
## [0.19.0](https://github.com/stonith404/pingvin-share/compare/v0.18.2...v0.19.0) (2023-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add OAuth2 login ([#276](https://github.com/stonith404/pingvin-share/issues/276)) ([02cd98f](https://github.com/stonith404/pingvin-share/commit/02cd98fa9cf9865d91494848aabaf42b19e4957b)), closes [#278](https://github.com/stonith404/pingvin-share/issues/278) [#279](https://github.com/stonith404/pingvin-share/issues/279) [#281](https://github.com/stonith404/pingvin-share/issues/281)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete unfinished shares after a day ([d327bc3](https://github.com/stonith404/pingvin-share/commit/d327bc355c8583231e058731934cf51ab25d9ce5))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,37 +1,41 @@
|
||||
# Using node slim because prisma ORM needs libc for ARM builds
|
||||
|
||||
# Stage 1: on frontend dependency change
|
||||
FROM node:19-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:19-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:19-slim AS backend-dependencies
|
||||
# Stage 3: Backend dependencies
|
||||
FROM node:20-alpine AS backend-dependencies
|
||||
RUN apk add --no-cache python3
|
||||
WORKDIR /opt/app
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 4:on backend change
|
||||
FROM node:19-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:19-slim AS runner
|
||||
FROM node:20-alpine AS runner
|
||||
ENV NODE_ENV=docker
|
||||
RUN apt-get update && apt-get install -y curl 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,8 +50,12 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
|
||||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 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
|
||||
|
||||
# HOSTNAME=127.0.0.1 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed
|
||||
CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=127.0.0.1 node frontend/server.js & cd backend && npm run prod
|
||||
# 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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_
|
||||
_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
@@ -63,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
|
||||
@@ -77,6 +79,10 @@ ClamAV is used to scan shares for malicious files and remove them if found.
|
||||
|
||||
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||
|
||||
#### OAuth 2 Login
|
||||
|
||||
View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information.
|
||||
|
||||
### Additional resources
|
||||
|
||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
1
backend/.prettierignore
Normal file
1
backend/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/src/constants.ts
|
||||
284
backend/package-lock.json
generated
284
backend/package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.17.2",
|
||||
"version": "0.21.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.17.2",
|
||||
"version": "0.21.1",
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^2.1.0",
|
||||
"@nestjs/common": "^10.1.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.2",
|
||||
@@ -21,6 +22,7 @@
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cache-manager": "^5.2.4",
|
||||
"clamscan": "^2.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@@ -28,6 +30,8 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "^3.3.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.9.4",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
@@ -52,6 +56,7 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/node-fetch": "^2.6.6",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-jwt": "^3.0.9",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
@@ -622,6 +627,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cache-manager": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz",
|
||||
"integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^9.0.0 || ^10.0.0",
|
||||
"cache-manager": "<=5",
|
||||
"reflect-metadata": "^0.1.12",
|
||||
"rxjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cli": {
|
||||
"version": "10.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz",
|
||||
@@ -1291,24 +1308,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/clamscan": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.4.tgz",
|
||||
"integrity": "sha512-NpD+EmE+ZK5WRJOAmeDuSYJIv15BUnc4PxQA+m3QNkutaPBZ7bmLDTvqBu2iDchs7YKQjiEQEwEMvsdwtdtImA==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.7.tgz",
|
||||
"integrity": "sha512-YopQF+D1pqbvMbvqTQx2MdxEqFbQAiPJKtjj/KnK9xxgmYkdt3dInkj/k6a+bztT4TexQ+tCUi/5D3LqDZIdhg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"axios": "^0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/clamscan/node_modules/axios": {
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
|
||||
@@ -1438,6 +1446,16 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
||||
"integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg=="
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz",
|
||||
"integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
||||
@@ -2250,13 +2268,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.9",
|
||||
"form-data": "^4.0.0"
|
||||
"follow-redirects": "^1.14.4"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
@@ -2525,6 +2542,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cache-manager": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz",
|
||||
"integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==",
|
||||
"dependencies": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^10.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cache-manager/node_modules/lru-cache": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz",
|
||||
"integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==",
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
@@ -5025,15 +5059,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.7.0.tgz",
|
||||
"integrity": "sha512-1/ugc8djfn93rTE3WRKdCzGGt/EtiYKxITMO4Wiv6q5JL1gl9ePt4kBsl1S499nbosspfctIQTpYIhSmHA3WAg==",
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz",
|
||||
"integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.0.0",
|
||||
"@hapi/topo": "^5.0.0",
|
||||
"@sideway/address": "^4.1.3",
|
||||
"@sideway/formula": "^3.0.0",
|
||||
"@sideway/formula": "^3.0.1",
|
||||
"@sideway/pinpoint": "^2.0.0"
|
||||
}
|
||||
},
|
||||
@@ -5248,6 +5282,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -5507,9 +5546,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
|
||||
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -5572,6 +5611,17 @@
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||
@@ -5733,9 +5783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
|
||||
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
@@ -6589,6 +6639,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||
@@ -7202,9 +7258,9 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.32.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.4.tgz",
|
||||
"integrity": "sha512-exUnZewqVZC6UXqXuQ8fyJJv0M968feBi04jb9GcUHrWtkRoAKnbJt8IfwT4NJs7FskArbJ14JAFGVuooszoGg==",
|
||||
"version": "0.32.6",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
|
||||
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
@@ -7833,7 +7889,7 @@
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
@@ -8193,16 +8249,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
|
||||
"integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||
"integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"joi": "^17.7.0",
|
||||
"axios": "^1.6.1",
|
||||
"joi": "^17.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.7",
|
||||
"rxjs": "^7.8.0"
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
@@ -8211,6 +8267,17 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on/node_modules/axios": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
|
||||
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||
@@ -8235,7 +8302,7 @@
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
@@ -8305,7 +8372,7 @@
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
@@ -8951,6 +9018,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nestjs/cache-manager": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz",
|
||||
"integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@nestjs/cli": {
|
||||
"version": "10.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz",
|
||||
@@ -9393,24 +9466,13 @@
|
||||
}
|
||||
},
|
||||
"@types/clamscan": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.4.tgz",
|
||||
"integrity": "sha512-NpD+EmE+ZK5WRJOAmeDuSYJIv15BUnc4PxQA+m3QNkutaPBZ7bmLDTvqBu2iDchs7YKQjiEQEwEMvsdwtdtImA==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.7.tgz",
|
||||
"integrity": "sha512-YopQF+D1pqbvMbvqTQx2MdxEqFbQAiPJKtjj/KnK9xxgmYkdt3dInkj/k6a+bztT4TexQ+tCUi/5D3LqDZIdhg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"axios": "^0.24.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": {
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"follow-redirects": "^1.14.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/connect": {
|
||||
@@ -9542,6 +9604,16 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
||||
"integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg=="
|
||||
},
|
||||
"@types/node-fetch": {
|
||||
"version": "2.6.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz",
|
||||
"integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/nodemailer": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
||||
@@ -10185,13 +10257,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"follow-redirects": "^1.14.9",
|
||||
"form-data": "^4.0.0"
|
||||
"follow-redirects": "^1.14.4"
|
||||
}
|
||||
},
|
||||
"b4a": {
|
||||
@@ -10386,6 +10457,22 @@
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
|
||||
},
|
||||
"cache-manager": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz",
|
||||
"integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==",
|
||||
"requires": {
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^10.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lru-cache": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz",
|
||||
"integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"call-bind": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
||||
@@ -12207,15 +12294,15 @@
|
||||
}
|
||||
},
|
||||
"joi": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.7.0.tgz",
|
||||
"integrity": "sha512-1/ugc8djfn93rTE3WRKdCzGGt/EtiYKxITMO4Wiv6q5JL1gl9ePt4kBsl1S499nbosspfctIQTpYIhSmHA3WAg==",
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz",
|
||||
"integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@hapi/hoek": "^9.0.0",
|
||||
"@hapi/topo": "^5.0.0",
|
||||
"@sideway/address": "^4.1.3",
|
||||
"@sideway/formula": "^3.0.0",
|
||||
"@sideway/formula": "^3.0.1",
|
||||
"@sideway/pinpoint": "^2.0.0"
|
||||
}
|
||||
},
|
||||
@@ -12394,6 +12481,11 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
|
||||
},
|
||||
"lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -12586,9 +12678,9 @@
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
|
||||
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
|
||||
},
|
||||
"minipass": {
|
||||
"version": "3.3.4",
|
||||
@@ -12636,6 +12728,11 @@
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"dev": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
|
||||
},
|
||||
"napi-build-utils": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||
@@ -12767,9 +12864,9 @@
|
||||
}
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
|
||||
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
@@ -13388,6 +13485,12 @@
|
||||
"ipaddr.js": "1.9.1"
|
||||
}
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||
@@ -13845,9 +13948,9 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"sharp": {
|
||||
"version": "0.32.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.4.tgz",
|
||||
"integrity": "sha512-exUnZewqVZC6UXqXuQ8fyJJv0M968feBi04jb9GcUHrWtkRoAKnbJt8IfwT4NJs7FskArbJ14JAFGVuooszoGg==",
|
||||
"version": "0.32.6",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
|
||||
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
|
||||
"requires": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.2",
|
||||
@@ -14306,7 +14409,7 @@
|
||||
},
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"tree-kill": {
|
||||
@@ -14553,16 +14656,29 @@
|
||||
}
|
||||
},
|
||||
"wait-on": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
|
||||
"integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||
"integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"axios": "^0.27.2",
|
||||
"joi": "^17.7.0",
|
||||
"axios": "^1.6.1",
|
||||
"joi": "^17.11.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.7",
|
||||
"rxjs": "^7.8.0"
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
|
||||
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"watchpack": {
|
||||
@@ -14586,7 +14702,7 @@
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"webpack": {
|
||||
@@ -14635,7 +14751,7 @@
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pingvin-share-backend",
|
||||
"version": "0.17.2",
|
||||
"version": "0.21.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
@@ -13,6 +13,7 @@
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^2.1.0",
|
||||
"@nestjs/common": "^10.1.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.2",
|
||||
@@ -26,6 +27,7 @@
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"body-parser": "^1.20.2",
|
||||
"cache-manager": "^5.2.4",
|
||||
"clamscan": "^2.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@@ -33,6 +35,8 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "^3.3.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.9.4",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
@@ -57,6 +61,7 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/node-fetch": "^2.6.6",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-jwt": "^3.0.9",
|
||||
"@types/qrcode-svg": "^1.1.1",
|
||||
|
||||
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuthUser" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerUserId" TEXT NOT NULL,
|
||||
"providerUsername" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"totpSecret" TEXT
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -14,7 +14,7 @@ model User {
|
||||
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
password String?
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
shares Share[]
|
||||
@@ -26,6 +26,8 @@ model User {
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
|
||||
oAuthUsers OAuthUser[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -60,6 +62,15 @@ model ResetPasswordToken {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OAuthUser {
|
||||
id String @id @default(uuid())
|
||||
provider String
|
||||
providerUserId String
|
||||
providerUsername String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
@@ -134,7 +145,7 @@ model Config {
|
||||
name String
|
||||
category String
|
||||
type String
|
||||
defaultValue String @default("")
|
||||
defaultValue String @default("")
|
||||
value String?
|
||||
obscured Boolean @default(false)
|
||||
secret Boolean @default(true)
|
||||
|
||||
@@ -5,7 +5,7 @@ const configVariables: ConfigVariables = {
|
||||
internal: {
|
||||
jwtSecret: {
|
||||
type: "string",
|
||||
defaultValue: crypto.randomBytes(256).toString("base64"),
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
@@ -37,6 +37,11 @@ const configVariables: ConfigVariables = {
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
maxExpiration: {
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
secret: false,
|
||||
},
|
||||
maxSize: {
|
||||
type: "number",
|
||||
defaultValue: "1000000000",
|
||||
@@ -119,6 +124,97 @@ const configVariables: ConfigVariables = {
|
||||
obscured: true,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
"allowRegistration": {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"ignoreTotp": {
|
||||
type: "boolean",
|
||||
defaultValue: "true",
|
||||
},
|
||||
"github-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"github-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"github-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"google-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"google-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"google-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"microsoft-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"microsoft-tenant": {
|
||||
type: "string",
|
||||
defaultValue: "common",
|
||||
},
|
||||
"microsoft-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"microsoft-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"discord-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"discord-limitedGuild": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"discord-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
"oidc-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"oidc-discoveryUri": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-usernameClaim": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
type ConfigVariables = {
|
||||
@@ -174,7 +270,7 @@ async function migrateConfigVariables() {
|
||||
for (const existingConfigVariable of existingConfigVariables) {
|
||||
const configVariable =
|
||||
configVariables[existingConfigVariable.category]?.[
|
||||
existingConfigVariable.name
|
||||
existingConfigVariable.name
|
||||
];
|
||||
if (!configVariable) {
|
||||
await prisma.config.delete({
|
||||
|
||||
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,9 @@ 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";
|
||||
import { OAuthModule } from "./oauth/oauth.module";
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,7 +35,12 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||
ScheduleModule.forRoot(),
|
||||
ClamScanModule,
|
||||
ReverseShareModule,
|
||||
OAuthModule,
|
||||
CacheModule.register({
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@@ -33,24 +33,24 @@ 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");
|
||||
|
||||
const result = await this.authService.signUp(dto);
|
||||
|
||||
response = this.addTokensToResponse(
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -61,15 +61,15 @@ 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);
|
||||
|
||||
if (result.accessToken && result.refreshToken) {
|
||||
response = this.addTokensToResponse(
|
||||
this.authService.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(
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
result.refreshToken,
|
||||
result.accessToken
|
||||
result.accessToken,
|
||||
);
|
||||
|
||||
return new TokenDTO().from(result);
|
||||
@@ -113,15 +113,15 @@ 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.password,
|
||||
dto.oldPassword,
|
||||
dto.password
|
||||
);
|
||||
|
||||
response = this.addTokensToResponse(response, result.refreshToken);
|
||||
this.authService.addTokensToResponse(response, result.refreshToken);
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@@ -129,21 +129,21 @@ 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);
|
||||
this.authService.addTokensToResponse(response, undefined, accessToken);
|
||||
return new TokenDTO().from({ accessToken });
|
||||
}
|
||||
|
||||
@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 });
|
||||
@@ -172,22 +172,4 @@ export class AuthController {
|
||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||
}
|
||||
|
||||
private addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||
if (refreshToken)
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), EmailModule],
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
}),
|
||||
EmailModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import * as argon from "argon2";
|
||||
import { Request, Response } from "express";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
@@ -21,13 +22,13 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||
|
||||
const hash = await argon.hash(dto.password);
|
||||
const hash = dto.password ? await argon.hash(dto.password) : null;
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@@ -39,17 +40,17 @@ export class AuthService {
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
return { accessToken, refreshToken, user };
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,16 +70,23 @@ export class AuthService {
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
async generateToken(user: User, isOAuth = false) {
|
||||
// TODO: Make all old loginTokens invalid when a new one is created
|
||||
// Check if the user has TOTP enabled
|
||||
if (user.totpVerified) {
|
||||
if (
|
||||
user.totpVerified &&
|
||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
||||
) {
|
||||
const loginToken = await this.createLoginToken(user.id);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||
user.id
|
||||
user.id,
|
||||
);
|
||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||
|
||||
@@ -129,9 +137,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
|
||||
const isPasswordValid =
|
||||
!user.password || !(await argon.verify(user.password, oldPassword));
|
||||
|
||||
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
|
||||
|
||||
const hash = await argon.hash(newPassword);
|
||||
|
||||
@@ -158,7 +168,7 @@ export class AuthService {
|
||||
{
|
||||
expiresIn: "15min",
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +199,7 @@ export class AuthService {
|
||||
|
||||
return this.createAccessToken(
|
||||
refreshTokenMetaData.user,
|
||||
refreshTokenMetaData.id
|
||||
refreshTokenMetaData.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,4 +220,38 @@ export class AuthService {
|
||||
|
||||
return loginToken;
|
||||
}
|
||||
|
||||
addTokensToResponse(
|
||||
response: Response,
|
||||
refreshToken?: string,
|
||||
accessToken?: string,
|
||||
) {
|
||||
if (accessToken)
|
||||
response.cookie("access_token", accessToken, { sameSite: "lax" });
|
||||
if (refreshToken)
|
||||
response.cookie("refresh_token", refreshToken, {
|
||||
path: "/api/auth/token",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user id if the user is logged in, null otherwise
|
||||
*/
|
||||
async getIdOfCurrentUser(request: Request): Promise<string | null> {
|
||||
if (!request.cookies.access_token) return null;
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(
|
||||
request.cookies.access_token,
|
||||
{
|
||||
secret: this.config.get("internal.jwtSecret"),
|
||||
},
|
||||
);
|
||||
return payload.sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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";
|
||||
@@ -18,47 +17,32 @@ export class AuthTotpService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService,
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||
if (!dto.email && !dto.username)
|
||||
throw new BadRequestException("Email or username is required");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
|
||||
const token = await this.prisma.loginToken.findFirst({
|
||||
where: {
|
||||
token: dto.loginToken,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!token || token.userId != user.id || token.used)
|
||||
if (!token || token.used)
|
||||
throw new UnauthorizedException("Invalid login token");
|
||||
|
||||
if (token.expiresAt < new Date())
|
||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||
|
||||
// Check the TOTP code
|
||||
const { totpSecret } = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { totpSecret: true },
|
||||
});
|
||||
const { totpSecret } = token.user;
|
||||
|
||||
if (!totpSecret) {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (dto.totp !== expected) {
|
||||
if (!authenticator.check(dto.totp, totpSecret)) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
}
|
||||
|
||||
@@ -69,10 +53,10 @@ export class AuthTotpService {
|
||||
});
|
||||
|
||||
const { refreshToken, refreshTokenId } =
|
||||
await this.authService.createRefreshToken(user.id);
|
||||
await this.authService.createRefreshToken(token.user.id);
|
||||
const accessToken = await this.authService.createAccessToken(
|
||||
user,
|
||||
refreshTokenId
|
||||
token.user,
|
||||
refreshTokenId,
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
@@ -92,13 +76,12 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is already enabled");
|
||||
}
|
||||
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
this.config.get("general.appName"),
|
||||
secret
|
||||
"pingvin-share",
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsString } from "class-validator";
|
||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||
|
||||
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
||||
export class AuthSignInTotpDTO {
|
||||
@IsString()
|
||||
totp: string;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
oldPassword: string;
|
||||
@IsOptional()
|
||||
oldPassword?: string;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ClamScanService {
|
||||
|
||||
constructor(
|
||||
private fileService: FileService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||
@@ -81,7 +81,7 @@ export class ClamScanService {
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,24 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
/**
|
||||
* ConfigService extends EventEmitter to allow listening for config updates,
|
||||
* now only `update` event will be emitted.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
export class ConfigService extends EventEmitter {
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -89,7 +96,7 @@ export class ConfigService {
|
||||
configVariable.type != "text"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Config variable must be of type ${configVariable.type}`
|
||||
`Config variable must be of type ${configVariable.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +112,8 @@ export class ConfigService {
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
this.emit("update", key, value);
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -25,7 +26,7 @@ export class LogoService {
|
||||
fs.promises.writeFile(
|
||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||
resized,
|
||||
"binary"
|
||||
"binary",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
"smtp.email",
|
||||
)}>`,
|
||||
to: email,
|
||||
subject,
|
||||
@@ -49,7 +49,7 @@ export class EmailService {
|
||||
shareId: string,
|
||||
creator?: User,
|
||||
description?: string,
|
||||
expiration?: Date
|
||||
expiration?: Date,
|
||||
) {
|
||||
if (!this.config.get("email.enableShareEmailRecipients"))
|
||||
throw new InternalServerErrorException("Email service disabled");
|
||||
@@ -69,8 +69,8 @@ export class EmailService {
|
||||
"{expires}",
|
||||
moment(expiration).unix() != 0
|
||||
? moment(expiration).fromNow()
|
||||
: "in: never"
|
||||
)
|
||||
: "in: never",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,13 +83,13 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.reverseShareMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{shareUrl}", shareUrl)
|
||||
.replaceAll("{shareUrl}", shareUrl),
|
||||
);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"general.appUrl"
|
||||
"general.appUrl",
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.sendMail(
|
||||
@@ -98,7 +98,7 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.resetPasswordMessage")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("{url}", resetPasswordUrl)
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export class EmailService {
|
||||
this.config
|
||||
.get("email.inviteMessage")
|
||||
.replaceAll("{url}", loginUrl)
|
||||
.replaceAll("{password}", password)
|
||||
.replaceAll("{password}", password),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export class EmailService {
|
||||
await this.getTransporter()
|
||||
.sendMail({
|
||||
from: `"${this.config.get("general.appName")}" <${this.config.get(
|
||||
"smtp.email"
|
||||
"smtp.email",
|
||||
)}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Test email",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
@@ -28,7 +29,7 @@ export class FileController {
|
||||
@Query() query: any,
|
||||
|
||||
@Body() body: string,
|
||||
@Param("shareId") shareId: string
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
const { id, name, chunkIndex, totalChunks } = query;
|
||||
|
||||
@@ -39,7 +40,7 @@ export class FileController {
|
||||
data,
|
||||
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||
{ id, name },
|
||||
shareId
|
||||
shareId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ 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({
|
||||
@@ -64,7 +65,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);
|
||||
|
||||
@@ -81,4 +82,14 @@ export class FileController {
|
||||
|
||||
return new StreamableFile(file.file);
|
||||
}
|
||||
|
||||
@Delete(":fileId")
|
||||
@SkipThrottle()
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async remove(
|
||||
@Param("fileId") fileId: string,
|
||||
@Param("shareId") shareId: string,
|
||||
) {
|
||||
await this.fileService.remove(shareId, fileId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ 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();
|
||||
|
||||
@@ -40,7 +40,7 @@ export class FileService {
|
||||
let diskFileSize: number;
|
||||
try {
|
||||
diskFileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
).size;
|
||||
} catch {
|
||||
diskFileSize = 0;
|
||||
@@ -62,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;
|
||||
@@ -74,23 +74,23 @@ export class FileService {
|
||||
) {
|
||||
throw new HttpException(
|
||||
"Max share size exceeded",
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
buffer
|
||||
buffer,
|
||||
);
|
||||
|
||||
const isLastChunk = chunk.index == chunk.total - 1;
|
||||
if (isLastChunk) {
|
||||
fs.renameSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
);
|
||||
const fileSize = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
|
||||
`${SHARE_DIRECTORY}/${shareId}/${file.id}`,
|
||||
).size;
|
||||
await this.prisma.file.create({
|
||||
data: {
|
||||
@@ -124,6 +124,18 @@ export class FileService {
|
||||
};
|
||||
}
|
||||
|
||||
async remove(shareId: string, fileId: string) {
|
||||
const fileMetaData = await this.prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
|
||||
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||
|
||||
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||
|
||||
await this.prisma.file.delete({ where: { id: fileId } });
|
||||
}
|
||||
|
||||
async deleteAllFiles(shareId: string) {
|
||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||
recursive: true,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export class JobsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private reverseShareService: ReverseShareService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@@ -56,11 +56,33 @@ export class JobsService {
|
||||
|
||||
if (expiredReverseShares.length > 0) {
|
||||
this.logger.log(
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`
|
||||
`Deleted ${expiredReverseShares.length} expired reverse shares`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 */6 * * *")
|
||||
async deleteUnfinishedShares() {
|
||||
const unfinishedShares = await this.prisma.share.findMany({
|
||||
where: {
|
||||
createdAt: { lt: moment().subtract(1, "day").toDate() },
|
||||
uploadLocked: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const unfinishedShare of unfinishedShares) {
|
||||
await this.prisma.share.delete({
|
||||
where: { id: unfinishedShare.id },
|
||||
});
|
||||
|
||||
await this.fileService.deleteAllFiles(unfinishedShare.id);
|
||||
}
|
||||
|
||||
if (unfinishedShares.length > 0) {
|
||||
this.logger.log(`Deleted ${unfinishedShares.length} unfinished shares`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
let filesDeleted = 0;
|
||||
@@ -77,7 +99,7 @@ export class JobsService {
|
||||
|
||||
for (const file of temporaryFiles) {
|
||||
const stats = fs.statSync(
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
|
||||
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`,
|
||||
);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
@@ -93,7 +115,7 @@ export class JobsService {
|
||||
this.logger.log(`Deleted ${filesDeleted} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
@Cron("1 * * * *")
|
||||
async deleteExpiredTokens() {
|
||||
const { count: refreshTokenCount } =
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
|
||||
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
state: string;
|
||||
}
|
||||
6
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
6
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface OAuthSignInDto {
|
||||
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
}
|
||||
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export class ErrorPageException extends Error {
|
||||
/**
|
||||
* Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix)
|
||||
* @param key i18n key of message
|
||||
* @param redirect redirect url
|
||||
* @param params message params (key)
|
||||
*/
|
||||
constructor(
|
||||
public readonly key: string = "default",
|
||||
public readonly redirect?: string,
|
||||
public readonly params?: string[],
|
||||
) {
|
||||
super("error");
|
||||
}
|
||||
}
|
||||
39
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
39
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
|
||||
@Catch(ErrorPageException)
|
||||
export class ErrorPageExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(ErrorPageExceptionFilter.name);
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
catch(exception: ErrorPageException, host: ArgumentsHost) {
|
||||
this.logger.error(
|
||||
JSON.stringify({
|
||||
error: exception.key,
|
||||
params: exception.params,
|
||||
redirect: exception.redirect,
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
|
||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
||||
url.searchParams.set("error", exception.key);
|
||||
if (exception.redirect) {
|
||||
url.searchParams.set("redirect", exception.redirect);
|
||||
} else {
|
||||
const redirect = ctx.getRequest().cookies.access_token
|
||||
? "/account"
|
||||
: "/auth/signIn";
|
||||
url.searchParams.set("redirect", redirect);
|
||||
}
|
||||
if (exception.params) {
|
||||
url.searchParams.set("params", exception.params.join(","));
|
||||
}
|
||||
|
||||
response.redirect(url.toString());
|
||||
}
|
||||
}
|
||||
38
backend/src/oauth/filter/oauthException.filter.ts
Normal file
38
backend/src/oauth/filter/oauthException.filter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
|
||||
@Catch(HttpException)
|
||||
export class OAuthExceptionFilter implements ExceptionFilter {
|
||||
private errorKeys: Record<string, string> = {
|
||||
access_denied: "access_denied",
|
||||
expired_token: "expired_token",
|
||||
};
|
||||
private readonly logger = new Logger(OAuthExceptionFilter.name);
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
this.logger.error(exception.message);
|
||||
this.logger.error(
|
||||
"Request query: " + JSON.stringify(request.query, null, 2),
|
||||
);
|
||||
|
||||
const key = this.errorKeys[request.query.error] || "default";
|
||||
|
||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
||||
url.searchParams.set("redirect", "/account");
|
||||
url.searchParams.set("error", key);
|
||||
|
||||
response.redirect(url.toString());
|
||||
}
|
||||
}
|
||||
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthGuard implements CanActivate {
|
||||
constructor() {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const provider = request.params.provider;
|
||||
return request.query.state === request.cookies[`oauth_${provider}_state`];
|
||||
}
|
||||
}
|
||||
24
backend/src/oauth/guard/provider.guard.ts
Normal file
24
backend/src/oauth/guard/provider.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class ProviderGuard implements CanActivate {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const provider = request.params.provider;
|
||||
return (
|
||||
this.platforms.includes(provider) &&
|
||||
this.config.get(`oauth.${provider}-enabled`)
|
||||
);
|
||||
}
|
||||
}
|
||||
110
backend/src/oauth/oauth.controller.ts
Normal file
110
backend/src/oauth/oauth.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request, Response } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { GetUser } from "../auth/decorator/getUser.decorator";
|
||||
import { JwtGuard } from "../auth/guard/jwt.guard";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { OAuthCallbackDto } from "./dto/oauthCallback.dto";
|
||||
import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter";
|
||||
import { OAuthGuard } from "./guard/oauth.guard";
|
||||
import { ProviderGuard } from "./guard/provider.guard";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
import { OAuthExceptionFilter } from "./filter/oauthException.filter";
|
||||
|
||||
@Controller("oauth")
|
||||
export class OAuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private oauthService: OAuthService,
|
||||
private config: ConfigService,
|
||||
@Inject("OAUTH_PROVIDERS")
|
||||
private providers: Record<string, OAuthProvider<unknown>>,
|
||||
) {}
|
||||
|
||||
@Get("available")
|
||||
available() {
|
||||
return this.oauthService.available();
|
||||
}
|
||||
|
||||
@Get("status")
|
||||
@UseGuards(JwtGuard)
|
||||
async status(@GetUser() user: User) {
|
||||
return this.oauthService.status(user);
|
||||
}
|
||||
|
||||
@Get("auth/:provider")
|
||||
@UseGuards(ProviderGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter)
|
||||
async auth(
|
||||
@Param("provider") provider: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const state = nanoid(16);
|
||||
const url = await this.providers[provider].getAuthEndpoint(state);
|
||||
response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" });
|
||||
response.redirect(url);
|
||||
}
|
||||
|
||||
@Get("callback/:provider")
|
||||
@UseGuards(ProviderGuard, OAuthGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter)
|
||||
async callback(
|
||||
@Param("provider") provider: string,
|
||||
@Query() query: OAuthCallbackDto,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const oauthToken = await this.providers[provider].getToken(query);
|
||||
const user = await this.providers[provider].getUserInfo(oauthToken, query);
|
||||
const id = await this.authService.getIdOfCurrentUser(request);
|
||||
|
||||
if (id) {
|
||||
await this.oauthService.link(
|
||||
id,
|
||||
provider,
|
||||
user.providerId,
|
||||
user.providerUsername,
|
||||
);
|
||||
response.redirect(this.config.get("general.appUrl") + "/account");
|
||||
} else {
|
||||
const token: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
loginToken?: string;
|
||||
} = await this.oauthService.signIn(user);
|
||||
if (token.accessToken) {
|
||||
this.authService.addTokensToResponse(
|
||||
response,
|
||||
token.refreshToken,
|
||||
token.accessToken,
|
||||
);
|
||||
response.redirect(this.config.get("general.appUrl"));
|
||||
} else {
|
||||
response.redirect(
|
||||
this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post("unlink/:provider")
|
||||
@UseGuards(JwtGuard, ProviderGuard)
|
||||
@UseFilters(ErrorPageExceptionFilter)
|
||||
unlink(@GetUser() user: User, @Param("provider") provider: string) {
|
||||
return this.oauthService.unlink(user, provider);
|
||||
}
|
||||
}
|
||||
56
backend/src/oauth/oauth.module.ts
Normal file
56
backend/src/oauth/oauth.module.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OAuthController } from "./oauth.controller";
|
||||
import { OAuthService } from "./oauth.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { GitHubProvider } from "./provider/github.provider";
|
||||
import { GoogleProvider } from "./provider/google.provider";
|
||||
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||
import { OidcProvider } from "./provider/oidc.provider";
|
||||
import { DiscordProvider } from "./provider/discord.provider";
|
||||
import { MicrosoftProvider } from "./provider/microsoft.provider";
|
||||
|
||||
@Module({
|
||||
controllers: [OAuthController],
|
||||
providers: [
|
||||
OAuthService,
|
||||
GitHubProvider,
|
||||
GoogleProvider,
|
||||
MicrosoftProvider,
|
||||
DiscordProvider,
|
||||
OidcProvider,
|
||||
{
|
||||
provide: "OAUTH_PROVIDERS",
|
||||
useFactory(
|
||||
github: GitHubProvider,
|
||||
google: GoogleProvider,
|
||||
microsoft: MicrosoftProvider,
|
||||
discord: DiscordProvider,
|
||||
oidc: OidcProvider,
|
||||
): Record<string, OAuthProvider<unknown>> {
|
||||
return {
|
||||
github,
|
||||
google,
|
||||
microsoft,
|
||||
discord,
|
||||
oidc,
|
||||
};
|
||||
},
|
||||
inject: [
|
||||
GitHubProvider,
|
||||
GoogleProvider,
|
||||
MicrosoftProvider,
|
||||
DiscordProvider,
|
||||
OidcProvider,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: "OAUTH_PLATFORMS",
|
||||
useFactory(providers: Record<string, OAuthProvider<unknown>>): string[] {
|
||||
return Object.keys(providers);
|
||||
},
|
||||
inject: ["OAUTH_PROVIDERS"],
|
||||
},
|
||||
],
|
||||
imports: [AuthModule],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
171
backend/src/oauth/oauth.service.ts
Normal file
171
backend/src/oauth/oauth.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private config: ConfigService,
|
||||
private auth: AuthService,
|
||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||
) {}
|
||||
|
||||
available(): string[] {
|
||||
return this.platforms
|
||||
.map((platform) => [
|
||||
platform,
|
||||
this.config.get(`oauth.${platform}-enabled`),
|
||||
])
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([platform, _]) => platform);
|
||||
}
|
||||
|
||||
async status(user: User) {
|
||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
||||
select: {
|
||||
provider: true,
|
||||
providerUsername: true,
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
return Object.fromEntries(oauthUsers.map((u) => [u.provider, u]));
|
||||
}
|
||||
|
||||
async signIn(user: OAuthSignInDto) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
return this.auth.generateToken(oauthUser.user, true);
|
||||
}
|
||||
|
||||
return this.signUp(user);
|
||||
}
|
||||
|
||||
async link(
|
||||
userId: string,
|
||||
provider: string,
|
||||
providerUserId: string,
|
||||
providerUsername: string,
|
||||
) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
provider,
|
||||
providerUserId,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
throw new ErrorPageException("already_linked", "/account", [
|
||||
`provider_${provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
userId,
|
||||
provider,
|
||||
providerUsername,
|
||||
providerUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unlink(user: User, provider: string) {
|
||||
const oauthUser = await this.prisma.oAuthUser.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.prisma.oAuthUser.delete({
|
||||
where: {
|
||||
id: oauthUser.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new ErrorPageException("not_linked", "/account", [provider]);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAvailableUsername(email: string) {
|
||||
// only remove + and - from email for now (maybe not enough)
|
||||
let username = email.split("@")[0].replace(/[+-]/g, "").substring(0, 20);
|
||||
while (true) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
username = username + "_" + nanoid(10).replaceAll("-", "");
|
||||
} else {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async signUp(user: OAuthSignInDto) {
|
||||
// register
|
||||
if (!this.config.get("oauth.allowRegistration")) {
|
||||
throw new ErrorPageException("no_user", "/auth/signIn", [
|
||||
`provider_${user.provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw new ErrorPageException("no_email", "/auth/signIn", [
|
||||
`provider_${user.provider}`,
|
||||
]);
|
||||
}
|
||||
|
||||
const existingUser: User = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId.toString(),
|
||||
providerUsername: user.providerUsername,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
});
|
||||
return this.auth.generateToken(existingUser, true);
|
||||
}
|
||||
|
||||
const result = await this.auth.signUp({
|
||||
email: user.email,
|
||||
username: await this.getAvailableUsername(user.email),
|
||||
password: null,
|
||||
});
|
||||
|
||||
await this.prisma.oAuthUser.create({
|
||||
data: {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId.toString(),
|
||||
providerUsername: user.providerUsername,
|
||||
userId: result.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
136
backend/src/oauth/provider/discord.provider.ts
Normal file
136
backend/src/oauth/provider/discord.provider.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import fetch from "node-fetch";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
@Injectable()
|
||||
export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getAuthEndpoint(state: string): Promise<string> {
|
||||
let scope = "identify email";
|
||||
if (this.config.get("oauth.discord-limitedGuild")) {
|
||||
scope += " guilds";
|
||||
}
|
||||
return Promise.resolve(
|
||||
"https://discord.com/api/oauth2/authorize?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.discord-clientId"),
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||
response_type: "code",
|
||||
state,
|
||||
scope,
|
||||
}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthorizationHeader() {
|
||||
return (
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
this.config.get("oauth.discord-clientId") +
|
||||
":" +
|
||||
this.config.get("oauth.discord-clientSecret"),
|
||||
).toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<DiscordToken>> {
|
||||
const res = await fetch("https://discord.com/api/v10/oauth2/token", {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: this.getAuthorizationHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code: query.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||
}),
|
||||
});
|
||||
const token: DiscordToken = await res.json();
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
expiresIn: token.expires_in,
|
||||
scope: token.scope,
|
||||
tokenType: token.token_type,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(token: OAuthToken<DiscordToken>): Promise<OAuthSignInDto> {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const user = (await res.json()) as DiscordUser;
|
||||
if (user.verified === false) {
|
||||
throw new ErrorPageException("unverified_account", undefined, [
|
||||
"provider_discord",
|
||||
]);
|
||||
}
|
||||
|
||||
const guild = this.config.get("oauth.discord-limitedGuild");
|
||||
if (guild) {
|
||||
await this.checkLimitedGuild(token, guild);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "discord",
|
||||
providerId: user.id,
|
||||
providerUsername: user.global_name ?? user.username,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
|
||||
try {
|
||||
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const guilds = (await res.json()) as DiscordPartialGuild[];
|
||||
if (!guilds.some((guild) => guild.id === guildId)) {
|
||||
throw new ErrorPageException("discord_guild_permission_denied");
|
||||
}
|
||||
} catch {
|
||||
throw new ErrorPageException("discord_guild_permission_denied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscordToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string;
|
||||
email: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordPartialGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
features: string[];
|
||||
}
|
||||
237
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
237
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import fetch from "node-fetch";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Cache } from "cache-manager";
|
||||
import { nanoid } from "nanoid";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
|
||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
protected discoveryUri: string;
|
||||
private configuration: OidcConfigurationCache;
|
||||
private jwk: OidcJwkCache;
|
||||
private logger: Logger = new Logger(
|
||||
Object.getPrototypeOf(this).constructor.name,
|
||||
);
|
||||
|
||||
protected constructor(
|
||||
protected name: string,
|
||||
protected keyOfConfigUpdateEvents: string[],
|
||||
protected config: ConfigService,
|
||||
protected jwtService: JwtService,
|
||||
protected cache: Cache,
|
||||
) {
|
||||
this.discoveryUri = this.getDiscoveryUri();
|
||||
this.config.addListener("update", (key: string, _: unknown) => {
|
||||
if (this.keyOfConfigUpdateEvents.includes(key)) {
|
||||
this.deinit();
|
||||
this.discoveryUri = this.getDiscoveryUri();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected getRedirectUri(): string {
|
||||
return `${this.config.get("general.appUrl")}/api/oauth/callback/${
|
||||
this.name
|
||||
}`;
|
||||
}
|
||||
|
||||
async getConfiguration(): Promise<OidcConfiguration> {
|
||||
if (!this.configuration || this.configuration.expires < Date.now()) {
|
||||
await this.fetchConfiguration();
|
||||
}
|
||||
return this.configuration.data;
|
||||
}
|
||||
|
||||
async getJwk(): Promise<OidcJwk[]> {
|
||||
if (!this.jwk || this.jwk.expires < Date.now()) {
|
||||
await this.fetchJwk();
|
||||
}
|
||||
return this.jwk.data;
|
||||
}
|
||||
|
||||
async getAuthEndpoint(state: string) {
|
||||
const configuration = await this.getConfiguration();
|
||||
const endpoint = configuration.authorization_endpoint;
|
||||
|
||||
const nonce = nanoid();
|
||||
await this.cache.set(
|
||||
`oauth-${this.name}-nonce-${state}`,
|
||||
nonce,
|
||||
1000 * 60 * 5,
|
||||
);
|
||||
|
||||
return (
|
||||
endpoint +
|
||||
"?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
state,
|
||||
nonce,
|
||||
}).toString()
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<OidcToken>> {
|
||||
const configuration = await this.getConfiguration();
|
||||
const endpoint = configuration.token_endpoint;
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.config.get(`oauth.${this.name}-clientId`),
|
||||
client_secret: this.config.get(`oauth.${this.name}-clientSecret`),
|
||||
grant_type: "authorization_code",
|
||||
code: query.code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}).toString(),
|
||||
});
|
||||
const token: OidcToken = await res.json();
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
expiresIn: token.expires_in,
|
||||
idToken: token.id_token,
|
||||
refreshToken: token.refresh_token,
|
||||
tokenType: token.token_type,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(
|
||||
token: OAuthToken<OidcToken>,
|
||||
query: OAuthCallbackDto,
|
||||
claim?: string
|
||||
): Promise<OAuthSignInDto> {
|
||||
const idTokenData = this.decodeIdToken(token.idToken);
|
||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
||||
|
||||
const key = `oauth-${this.name}-nonce-${query.state}`;
|
||||
const nonce = await this.cache.get(key);
|
||||
await this.cache.del(key);
|
||||
if (nonce !== idTokenData.nonce) {
|
||||
this.logger.error(
|
||||
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
|
||||
);
|
||||
throw new ErrorPageException("invalid_token");
|
||||
}
|
||||
|
||||
const username = claim
|
||||
? idTokenData[claim]
|
||||
: idTokenData.name ||
|
||||
idTokenData.nickname ||
|
||||
idTokenData.preferred_username;
|
||||
|
||||
if (!username) {
|
||||
this.logger.error(
|
||||
`Can not get username from ID Token ${JSON.stringify(
|
||||
idTokenData,
|
||||
undefined,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
throw new ErrorPageException("cannot_get_user_info", undefined, [
|
||||
`provider_${this.name}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: this.name as any,
|
||||
email: idTokenData.email,
|
||||
providerId: idTokenData.sub,
|
||||
providerUsername: username,
|
||||
};
|
||||
}
|
||||
|
||||
protected abstract getDiscoveryUri(): string;
|
||||
|
||||
private async fetchConfiguration(): Promise<void> {
|
||||
const res = await fetch(this.discoveryUri);
|
||||
const expires = res.headers.has("expires")
|
||||
? new Date(res.headers.get("expires")).getTime()
|
||||
: Date.now() + 1000 * 60 * 60 * 24;
|
||||
this.configuration = {
|
||||
expires,
|
||||
data: await res.json(),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchJwk(): Promise<void> {
|
||||
const configuration = await this.getConfiguration();
|
||||
const res = await fetch(configuration.jwks_uri);
|
||||
const expires = res.headers.has("expires")
|
||||
? new Date(res.headers.get("expires")).getTime()
|
||||
: Date.now() + 1000 * 60 * 60 * 24;
|
||||
this.jwk = {
|
||||
expires,
|
||||
data: (await res.json())["keys"],
|
||||
};
|
||||
}
|
||||
|
||||
private deinit() {
|
||||
this.discoveryUri = undefined;
|
||||
this.configuration = undefined;
|
||||
this.jwk = undefined;
|
||||
}
|
||||
|
||||
private decodeIdToken(idToken: string): OidcIdToken {
|
||||
return this.jwtService.decode(idToken) as OidcIdToken;
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcCache<T> {
|
||||
expires: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface OidcConfiguration {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
jwks_uri: string;
|
||||
response_types_supported: string[];
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
scopes_supported?: string[];
|
||||
claims_supported?: string[];
|
||||
}
|
||||
|
||||
export interface OidcJwk {
|
||||
e: string;
|
||||
alg: string;
|
||||
kid: string;
|
||||
use: string;
|
||||
kty: string;
|
||||
n: string;
|
||||
}
|
||||
|
||||
export type OidcConfigurationCache = OidcCache<OidcConfiguration>;
|
||||
|
||||
export type OidcJwkCache = OidcCache<OidcJwk[]>;
|
||||
|
||||
export interface OidcToken {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
id_token: string;
|
||||
}
|
||||
|
||||
export interface OidcIdToken {
|
||||
iss: string;
|
||||
sub: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
email: string;
|
||||
name: string;
|
||||
nickname: string;
|
||||
preferred_username: string;
|
||||
nonce: string;
|
||||
}
|
||||
112
backend/src/oauth/provider/github.provider.ts
Normal file
112
backend/src/oauth/provider/github.provider.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import fetch from "node-fetch";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getAuthEndpoint(state: string): Promise<string> {
|
||||
return Promise.resolve(
|
||||
"https://github.com/login/oauth/authorize?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.github-clientId"),
|
||||
redirect_uri:
|
||||
this.config.get("general.appUrl") + "/api/oauth/callback/github",
|
||||
state: state,
|
||||
scope: "user:email",
|
||||
}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
async getToken(query: OAuthCallbackDto): Promise<OAuthToken<GitHubToken>> {
|
||||
const res = await fetch(
|
||||
"https://github.com/login/oauth/access_token?" +
|
||||
new URLSearchParams({
|
||||
client_id: this.config.get("oauth.github-clientId"),
|
||||
client_secret: this.config.get("oauth.github-clientSecret"),
|
||||
code: query.code,
|
||||
}).toString(),
|
||||
{
|
||||
method: "post",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const token: GitHubToken = await res.json();
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
tokenType: token.token_type,
|
||||
scope: token.scope,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
|
||||
if (!token.scope.includes("user:email")) {
|
||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||
}
|
||||
const user = await this.getGitHubUser(token);
|
||||
const email = await this.getGitHubEmail(token);
|
||||
if (!email) {
|
||||
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "github",
|
||||
providerId: user.id.toString(),
|
||||
providerUsername: user.name ?? user.login,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
private async getGitHubUser(
|
||||
token: OAuthToken<GitHubToken>,
|
||||
): Promise<GitHubUser> {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
return (await res.json()) as GitHubUser;
|
||||
}
|
||||
|
||||
private async getGitHubEmail(
|
||||
token: OAuthToken<GitHubToken>,
|
||||
): Promise<string | undefined> {
|
||||
const res = await fetch("https://api.github.com/user/public_emails", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`,
|
||||
},
|
||||
});
|
||||
const emails = (await res.json()) as GitHubEmail[];
|
||||
return emails.find((e) => e.primary && e.verified)?.email;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface GitHubUser {
|
||||
login: string;
|
||||
id: number;
|
||||
name?: string;
|
||||
email?: string; // this filed seems only return null
|
||||
}
|
||||
|
||||
export interface GitHubEmail {
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
visibility: string | null;
|
||||
}
|
||||
21
backend/src/oauth/provider/google.provider.ts
Normal file
21
backend/src/oauth/provider/google.provider.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) cache: Cache,
|
||||
) {
|
||||
super("google", ["oauth.google-enabled"], config, jwtService, cache);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return "https://accounts.google.com/.well-known/openid-configuration";
|
||||
}
|
||||
}
|
||||
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { GenericOidcProvider } from "./genericOidc.provider";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) cache: Cache,
|
||||
) {
|
||||
super(
|
||||
"microsoft",
|
||||
["oauth.microsoft-enabled", "oauth.microsoft-tenant"],
|
||||
config,
|
||||
jwtService,
|
||||
cache,
|
||||
);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return `https://login.microsoftonline.com/${this.config.get(
|
||||
"oauth.microsoft-tenant",
|
||||
)}/v2.0/.well-known/openid-configuration`;
|
||||
}
|
||||
}
|
||||
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
|
||||
/**
|
||||
* @typeParam T - type of token
|
||||
* @typeParam C - type of callback query
|
||||
*/
|
||||
export interface OAuthProvider<T, C = OAuthCallbackDto> {
|
||||
getAuthEndpoint(state: string): Promise<string>;
|
||||
|
||||
getToken(query: C): Promise<OAuthToken<T>>;
|
||||
|
||||
getUserInfo(token: OAuthToken<T>, query: C): Promise<OAuthSignInDto>;
|
||||
}
|
||||
|
||||
export interface OAuthToken<T> {
|
||||
accessToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType?: string;
|
||||
scope?: string;
|
||||
idToken?: string;
|
||||
rawToken: T;
|
||||
}
|
||||
39
backend/src/oauth/provider/oidc.provider.ts
Normal file
39
backend/src/oauth/provider/oidc.provider.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { GenericOidcProvider, OidcToken } from "./genericOidc.provider";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||
import { OAuthToken } from "./oauthProvider.interface";
|
||||
|
||||
@Injectable()
|
||||
export class OidcProvider extends GenericOidcProvider {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
jwtService: JwtService,
|
||||
@Inject(CACHE_MANAGER) protected cache: Cache,
|
||||
) {
|
||||
super(
|
||||
"oidc",
|
||||
["oauth.oidc-enabled", "oauth.oidc-discoveryUri"],
|
||||
config,
|
||||
jwtService,
|
||||
cache,
|
||||
);
|
||||
}
|
||||
|
||||
protected getDiscoveryUri(): string {
|
||||
return this.config.get("oauth.oidc-discoveryUri");
|
||||
}
|
||||
|
||||
getUserInfo(
|
||||
token: OAuthToken<OidcToken>,
|
||||
query: OAuthCallbackDto,
|
||||
_?: string,
|
||||
): Promise<OAuthSignInDto> {
|
||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
||||
return super.getUserInfo(token, query, claim);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -10,7 +11,7 @@ export class ReverseShareService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private fileService: FileService
|
||||
private fileService: FileService,
|
||||
) {}
|
||||
|
||||
async create(data: CreateReverseShareDTO, creatorId: string) {
|
||||
@@ -19,16 +20,27 @@ export class ReverseShareService {
|
||||
.add(
|
||||
data.shareExpiration.split("-")[0],
|
||||
data.shareExpiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
"-",
|
||||
)[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.toDate();
|
||||
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(data.shareExpiration);
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
parsedExpiration >
|
||||
moment().add(this.config.get("share.maxExpiration"), "hours").toDate()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
);
|
||||
}
|
||||
|
||||
const globalMaxShareSize = this.config.get("share.maxSize");
|
||||
|
||||
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({
|
||||
|
||||
@@ -27,7 +27,7 @@ export class MyShareDTO extends OmitType(ShareDTO, [
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -7,16 +6,23 @@ import {
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class ShareOwnerGuard implements CanActivate {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
export class ShareOwnerGuard extends JwtGuard {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
const shareId = Object.prototype.hasOwnProperty.call(
|
||||
request.params,
|
||||
"shareId"
|
||||
"shareId",
|
||||
)
|
||||
? request.params.shareId
|
||||
: request.params.id;
|
||||
@@ -30,6 +36,8 @@ export class ShareOwnerGuard implements CanActivate {
|
||||
|
||||
if (!share.creatorId) return true;
|
||||
|
||||
if (!(await super.canActivate(context))) return false;
|
||||
|
||||
return share.creatorId == (request.user as User).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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ export class ShareController {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/from-owner")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async getFromOwner(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.get(id));
|
||||
}
|
||||
|
||||
@Get(":id/metaData")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
async getMetaData(@Param("id") id: string) {
|
||||
@@ -54,30 +60,36 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||
async remove(@Param("id") id: string) {
|
||||
await this.shareService.remove(id);
|
||||
}
|
||||
|
||||
@Post(":id/complete")
|
||||
@HttpCode(202)
|
||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/complete")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async revertComplete(@Param("id") id: string) {
|
||||
return new ShareDTO().from(await this.shareService.revertComplete(id));
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(ShareOwnerGuard)
|
||||
async remove(@Param("id") id: string) {
|
||||
await this.shareService.remove(id);
|
||||
}
|
||||
|
||||
@Throttle(10, 60)
|
||||
@Get("isShareIdAvailable/:id")
|
||||
async isShareIdAvailable(@Param("id") id: string) {
|
||||
@@ -91,7 +103,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 { parseRelativeDateToAbsolute } from "src/utils/date.util";
|
||||
import { SHARE_DIRECTORY } from "../constants";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
|
||||
@@ -28,7 +29,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) {
|
||||
@@ -46,24 +47,29 @@ 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;
|
||||
} else {
|
||||
// We have to add an exception for "never" (since moment won't like that)
|
||||
if (share.expiration !== "never") {
|
||||
expirationDate = moment()
|
||||
.add(
|
||||
share.expiration.split("-")[0],
|
||||
share.expiration.split(
|
||||
"-"
|
||||
)[1] as moment.unitOfTime.DurationConstructor
|
||||
)
|
||||
.toDate();
|
||||
} else {
|
||||
expirationDate = moment(0).toDate();
|
||||
const parsedExpiration = parseRelativeDateToAbsolute(share.expiration);
|
||||
|
||||
const expiresNever = moment(0).toDate() == parsedExpiration;
|
||||
|
||||
if (
|
||||
this.config.get("share.maxExpiration") !== 0 &&
|
||||
(expiresNever ||
|
||||
parsedExpiration >
|
||||
moment()
|
||||
.add(this.config.get("share.maxExpiration"), "hours")
|
||||
.toDate())
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Expiration date exceeds maximum expiration date",
|
||||
);
|
||||
}
|
||||
|
||||
expirationDate = parsedExpiration;
|
||||
}
|
||||
|
||||
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
|
||||
@@ -134,13 +140,13 @@ 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 recipient
|
||||
@@ -150,7 +156,7 @@ export class ShareService {
|
||||
share.id,
|
||||
share.creator,
|
||||
share.description,
|
||||
share.expiration
|
||||
share.expiration,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +167,7 @@ export class ShareService {
|
||||
) {
|
||||
await this.emailService.sendMailToReverseShareCreator(
|
||||
share.reverseShare.creator.email,
|
||||
share.id
|
||||
share.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +187,13 @@ export class ShareService {
|
||||
});
|
||||
}
|
||||
|
||||
async revertComplete(id: string) {
|
||||
return this.prisma.share.update({
|
||||
where: { id },
|
||||
data: { uploadLocked: false, isZipReady: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getSharesByUser(userId: string) {
|
||||
const shares = await this.prisma.share.findMany({
|
||||
where: {
|
||||
@@ -285,7 +298,7 @@ export class ShareService {
|
||||
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||
throw new ForbiddenException(
|
||||
"Maximum views exceeded",
|
||||
"share_max_views_exceeded"
|
||||
"share_max_views_exceeded",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,7 +318,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),
|
||||
) {}
|
||||
|
||||
@@ -16,6 +16,9 @@ export class UserDTO {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@@ -31,7 +34,7 @@ export class UserDTO {
|
||||
|
||||
fromList(partial: Partial<UserDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ export class UserController {
|
||||
@Get("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user: User) {
|
||||
return new UserDTO().from(user);
|
||||
const userDTO = new UserDTO().from(user);
|
||||
userDTO.hasPassword = !!user.password;
|
||||
return userDTO;
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@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 +46,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", "", {
|
||||
|
||||
@@ -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() {
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/utils/date.util.ts
Normal file
12
backend/src/utils/date.util.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as moment from "moment";
|
||||
|
||||
export function parseRelativeDateToAbsolute(relativeDate: string) {
|
||||
if (relativeDate == "never") return moment(0).toDate();
|
||||
|
||||
return moment()
|
||||
.add(
|
||||
relativeDate.split("-")[0],
|
||||
relativeDate.split("-")[1] as moment.unitOfTime.DurationConstructor,
|
||||
)
|
||||
.toDate();
|
||||
}
|
||||
@@ -6,7 +6,10 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2017",
|
||||
"target": "es2021",
|
||||
"lib": [
|
||||
"ES2021"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
|
||||
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_
|
||||
_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,8 @@ Pingvin Share es una plataforma de intercambio de archivos autoalojada y una alt
|
||||
## 🐧 Conoce Pingvin Share
|
||||
|
||||
- [Demo](https://pingvin-share.dev.eliasschneider.com)
|
||||
- [Reseña por DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
- [Reseña realizada por No Solo Hacking (español)](https://www.youtube.com/watch?v=ocd4EpLTYkU)
|
||||
- [Reseña por DB Tech (inglés)](https://www.youtube.com/watch?v=rWwNeZCOPJA)
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
|
||||
|
||||
|
||||
158
docs/README.ja-jp.md
Normal file
158
docs/README.ja-jp.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# <div align="center"><img src="https://user-images.githubusercontent.com/58886915/166198400-c2134044-1198-4647-a8b6-da9c4a204c68.svg" width="40"/> </br>Pingvin Share</div>
|
||||
|
||||
---
|
||||
|
||||
_READMEを別の言語で読む: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.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は、早期段階であり、バグが含まれている場合があります。
|
||||
|
||||
### Dockerでインストール (おすすめ)
|
||||
|
||||
1. `docker-compose.yml`ファイルをダウンロード
|
||||
2. `docker-compose up -d`を実行
|
||||
|
||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
||||
|
||||
### スタンドアローンインストール
|
||||
|
||||
必要なツール:
|
||||
|
||||
- [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`)
|
||||
|
||||
# バックエンドを開始
|
||||
cd backend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-backend" npm -- run prod
|
||||
|
||||
#フロントエンドを開始
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run build
|
||||
pm2 start --name="pingvin-share-frontend" npm -- run start
|
||||
```
|
||||
|
||||
Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧!
|
||||
|
||||
### 連携機能
|
||||
|
||||
#### ClamAV (Dockerのみ)
|
||||
|
||||
ClamAVは、共有されたファイルをスキャンし、感染したファイルを見つけた場合に削除するために使用されます。
|
||||
|
||||
1. ClamAVコンテナをDocker Composeの定義ファイル(`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)をご確認ください。
|
||||
|
||||
### 追加情報
|
||||
|
||||
- [Synology NASへのインストール方法](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||
|
||||
### 新しいバージョンへのアップグレード
|
||||
|
||||
Pingvin Shareは早期段階のため、アップグレード前に必ずリリースノートを確認して、アップグレードしても問題ないかどうかご確認ください。
|
||||
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### スタンドアローン
|
||||
|
||||
1. アプリを停止する
|
||||
```bash
|
||||
pm2 stop pingvin-share-backend pingvin-share-frontend
|
||||
```
|
||||
2. `git clone`のステップを除いて、[インストールガイド](#stand-alone-installation)をくり返してください。
|
||||
|
||||
```bash
|
||||
cd pingvin-share
|
||||
|
||||
# 最新バージョンをチェックアウト
|
||||
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
|
||||
# バックエンドを開始
|
||||
cd backend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-backend
|
||||
|
||||
#フロントエンドを開始
|
||||
cd ../frontend
|
||||
npm run build
|
||||
pm2 restart pingvin-share-frontend
|
||||
```
|
||||
|
||||
### 設定
|
||||
|
||||
管理者のダッシュボード内の「設定」ページから、Pingvin Shareをカスタマイズできます。
|
||||
|
||||
#### 環境変数
|
||||
|
||||
インストール時の特定の設定で、環境変数を使用できます。次の環境変数が使用可能です:
|
||||
|
||||
##### バックエンド
|
||||
|
||||
| 変数名 | デフォルト値 | 説明 |
|
||||
| ---------------- | -------------------------------------------------- | -------------------------------------- |
|
||||
| `PORT` | `8080` | バックエンドがリッスンするポート番号 |
|
||||
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | SQLiteのURL |
|
||||
| `DATA_DIRECTORY` | `./data` | データを保管するディレクトリ |
|
||||
| `CLAMAV_HOST` | `127.0.0.1` | ClamAVサーバーのIPアドレス |
|
||||
| `CLAMAV_PORT` | `3310` | ClamAVサーバーのポート番号 |
|
||||
|
||||
##### フロントエンド
|
||||
|
||||
| 変数名 | デフォルト値 | 説明 |
|
||||
| --------- | ----------------------- | ---------------------------------------- |
|
||||
| `PORT` | `3000` | フロントエンドがリッスンするポート番号 |
|
||||
| `API_URL` | `http://localhost:8080` | フロントエンドからアクセスするバックエンドへのURL |
|
||||
|
||||
## 🖤 コントリビュート
|
||||
|
||||
### 翻訳
|
||||
|
||||
Pingvin Shareをあなたが使用している言語に翻訳するお手伝いを募集しています。
|
||||
[Crowdin](https://crowdin.com/project/pingvin-share)上で、簡単にPingvin Shareの翻訳作業への参加が可能です。
|
||||
|
||||
あなたの言語がありませんか? 気軽に[リクエスト](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)してください。
|
||||
|
||||
翻訳中に問題がありましたか? [ローカライズに関するディスカッション](https://github.com/stonith404/pingvin-share/discussions/198)に是非参加してください。
|
||||
|
||||
### プロジェクト
|
||||
|
||||
Pingvin Shareへのコントリビュートをいつでもお待ちしています! [コントリビューションガイド](/CONTRIBUTING.md)を確認して、是非参加してください。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
|
||||
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_
|
||||
_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md), [日本语](/docs/README.ja-jp.md)_
|
||||
|
||||
---
|
||||
|
||||
|
||||
157
docs/oauth2-guide.md
Normal file
157
docs/oauth2-guide.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# OAuth 2 Login Guide
|
||||
|
||||
## Config Built-in OAuth 2 Providers
|
||||
|
||||
- [GitHub](#github)
|
||||
- [Google](#google)
|
||||
- [Microsoft](#microsoft)
|
||||
- [Discord](#discord)
|
||||
- [OpenID Connect](#openid-connect)
|
||||
|
||||
### GitHub
|
||||
|
||||
Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) to create an OAuth app.
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/github`
|
||||
|
||||
### Google
|
||||
|
||||
Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to create an OAuth 2.0 App.
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/google`
|
||||
|
||||
### Microsoft
|
||||
|
||||
Please follow the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) to register an application.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Microsoft Tenant** you set in the admin panel must match the **supported account types** you set in the Microsoft Entra admin center, otherwise the OAuth login will not work. Refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#find-your-apps-openid-configuration-document-uri) for more details.
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/microsoft`
|
||||
|
||||
### Discord
|
||||
|
||||
Create an application on [Discord Developer Portal](https://discord.com/developers/applications).
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
|
||||
|
||||
### OpenID Connect
|
||||
|
||||
Generic OpenID Connect provider is also supported, we have tested it on Keycloak, Authentik and Casdoor.
|
||||
|
||||
Redirect URL: `https://<your-domain>/api/oauth/callback/oidc`
|
||||
|
||||
## Custom your OAuth 2 Provider
|
||||
|
||||
If our built-in providers don't meet your needs, you can create your own OAuth 2 provider.
|
||||
|
||||
### 1. Create config
|
||||
|
||||
Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts):
|
||||
|
||||
```ts
|
||||
const configVariables: ConfigVariables = {
|
||||
// ...
|
||||
oauth: {
|
||||
// ...
|
||||
"YOUR_PROVIDER_NAME-enabled": {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
},
|
||||
"YOUR_PROVIDER_NAME-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"YOUR_PROVIDER_NAME-clientSecret": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
obscured: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create provider class
|
||||
|
||||
#### Generic OpenID Connect
|
||||
|
||||
If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider.
|
||||
|
||||
The [Google provider](../backend/src/oauth/provider/google.provider.ts) and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples.
|
||||
|
||||
Here are some discovery URIs for popular providers:
|
||||
|
||||
- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration`
|
||||
- Google: `https://accounts.google.com/.well-known/openid-configuration`
|
||||
- Apple: `https://appleid.apple.com/.well-known/openid-configuration`
|
||||
- Gitlab: `https://gitlab.com/.well-known/openid-configuration`
|
||||
- Huawei: `https://oauth-login.cloud.huawei.com/.well-known/openid-configuration`
|
||||
- Paypal: `https://www.paypal.com/.well-known/openid-configuration`
|
||||
- Yahoo: `https://api.login.yahoo.com/.well-known/openid-configuration`
|
||||
|
||||
#### OAuth 2
|
||||
|
||||
If your provider only supports OAuth 2, you can implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider.
|
||||
|
||||
The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples.
|
||||
|
||||
### 3. Register provider
|
||||
|
||||
Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts):
|
||||
|
||||
```ts
|
||||
@Module({
|
||||
providers: [
|
||||
GitHubProvider,
|
||||
// your provider
|
||||
{
|
||||
provide: "OAUTH_PROVIDERS",
|
||||
useFactory(github: GitHubProvider, /* your provider */): Record<string, OAuthProvider<unknown>> {
|
||||
return {
|
||||
github,
|
||||
/* your provider */
|
||||
};
|
||||
},
|
||||
inject: [GitHubProvider, /* your provider */],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class OAuthModule {
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
export interface OAuthSignInDto {
|
||||
provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/;
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add frontend icon
|
||||
|
||||
Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx).
|
||||
|
||||
```tsx
|
||||
const getOAuthIcon = (provider: string) => {
|
||||
return {
|
||||
'github': <SiGithub />,
|
||||
/* your provider */
|
||||
}[provider];
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add i18n text
|
||||
|
||||
Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts).
|
||||
|
||||
- `signIn.oauth.YOUR_PROVIDER_NAME`
|
||||
- `account.card.oauth.YOUR_PROVIDER_NAME`
|
||||
- `admin.config.oauth.YOUR_PROVIDER_NAME-enabled`
|
||||
- `admin.config.oauth.YOUR_PROVIDER_NAME-client-id`
|
||||
- `admin.config.oauth.YOUR_PROVIDER_NAME-client-secret`
|
||||
- `error.param.provider_YOUR_PROVIDER_NAME`
|
||||
- Other config keys you defined in step 1
|
||||
|
||||
Congratulations! 🎉 You have successfully added a new OAuth 2 provider! Pull requests are welcome if you want to share your provider with others.
|
||||
@@ -11,6 +11,7 @@
|
||||
"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/*
|
||||
642
frontend/package-lock.json
generated
642
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "pingvin-share-frontend",
|
||||
"version": "0.17.2",
|
||||
"version": "0.21.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "node .next/standalone/server.js",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --end-of-line=auto --write \"src/**/*.ts*\""
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LOCALES } from "../../i18n/locales";
|
||||
const LanguagePicker = () => {
|
||||
const t = useTranslate();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(
|
||||
getCookie("language")?.toString()
|
||||
getCookie("language")?.toString(),
|
||||
);
|
||||
|
||||
const languages = Object.values(LOCALES).map((locale) => ({
|
||||
@@ -23,7 +23,7 @@ const LanguagePicker = () => {
|
||||
setCookie("language", value, {
|
||||
sameSite: "lax",
|
||||
expires: new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() + 1)
|
||||
new Date().setFullYear(new Date().getFullYear() + 1),
|
||||
),
|
||||
});
|
||||
location.reload();
|
||||
|
||||
@@ -14,7 +14,7 @@ import userPreferences from "../../utils/userPreferences.util";
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const [colorScheme, setColorScheme] = useState(
|
||||
userPreferences.get("colorScheme")
|
||||
userPreferences.get("colorScheme"),
|
||||
);
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
const systemColorScheme = useColorScheme();
|
||||
@@ -26,7 +26,7 @@ const ThemeSwitcher = () => {
|
||||
userPreferences.set("colorScheme", value);
|
||||
setColorScheme(value);
|
||||
toggleColorScheme(
|
||||
value == "system" ? systemColorScheme : (value as ColorScheme)
|
||||
value == "system" ? systemColorScheme : (value as ColorScheme),
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
|
||||
@@ -26,7 +26,7 @@ const showEnableTotpModal = (
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
password: string;
|
||||
}
|
||||
},
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
const showReverseShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
reverseShareToken: string,
|
||||
appUrl: string
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/upload/${reverseShareToken}`;
|
||||
|
||||
@@ -12,7 +12,7 @@ const showShareInformationsModal = (
|
||||
modals: ModalsContextProps,
|
||||
share: MyShare,
|
||||
appUrl: string,
|
||||
maxShareSize: number
|
||||
maxShareSize: number,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${share.id}`;
|
||||
@@ -36,28 +36,28 @@ const showShareInformationsModal = (
|
||||
|
||||
children: (
|
||||
<Stack align="stretch" spacing="md">
|
||||
<Text size="sm" color="lightgray">
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.id" />:{" "}
|
||||
</b>
|
||||
{share.id}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.description" />:{" "}
|
||||
</b>
|
||||
{share.description || "No description"}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
|
||||
</b>
|
||||
{formattedCreatedAt}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="lightgray">
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.expiresAt" />:{" "}
|
||||
</b>
|
||||
@@ -66,7 +66,7 @@ const showShareInformationsModal = (
|
||||
<Divider />
|
||||
<CopyTextField link={link} />
|
||||
<Divider />
|
||||
<Text size="sm" color="lightgray">
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.size" />:{" "}
|
||||
</b>
|
||||
@@ -76,7 +76,7 @@ const showShareInformationsModal = (
|
||||
|
||||
<Flex align="center" justify="center">
|
||||
{shareSize / maxShareSize < 0.1 && (
|
||||
<Text size="xs" color="lightgray" style={{ marginRight: "4px" }}>
|
||||
<Text size="xs" style={{ marginRight: "4px" }}>
|
||||
{formattedShareSize}
|
||||
</Text>
|
||||
)}
|
||||
@@ -87,7 +87,7 @@ const showShareInformationsModal = (
|
||||
size="xl"
|
||||
radius="xl"
|
||||
/>
|
||||
<Text size="xs" color="lightgray" style={{ marginLeft: "4px" }}>
|
||||
<Text size="xs" style={{ marginLeft: "4px" }}>
|
||||
{formattedMaxShareSize}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { translateOutsideContext } from "../../hooks/useTranslate.hook";
|
||||
const showShareLinkModal = (
|
||||
modals: ModalsContextProps,
|
||||
shareId: string,
|
||||
appUrl: string
|
||||
appUrl: string,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
const link = `${appUrl}/s/${shareId}`;
|
||||
|
||||
@@ -21,7 +21,7 @@ const AdminConfigInput = ({
|
||||
stringValue: configVariable.value ?? configVariable.defaultValue,
|
||||
textValue: configVariable.value ?? configVariable.defaultValue,
|
||||
numberValue: parseInt(
|
||||
configVariable.value ?? configVariable.defaultValue
|
||||
configVariable.value ?? configVariable.defaultValue,
|
||||
),
|
||||
booleanValue:
|
||||
(configVariable.value ?? configVariable.defaultValue) == "true",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||
import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const categories = [
|
||||
@@ -19,6 +19,7 @@ const categories = [
|
||||
{ name: "Email", icon: <TbMail /> },
|
||||
{ name: "Share", icon: <TbShare /> },
|
||||
{ name: "SMTP", icon: <TbAt /> },
|
||||
{ name: "OAuth", icon: <TbSocial /> },
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
|
||||
@@ -33,7 +33,7 @@ const TestEmailButton = ({
|
||||
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||
</Stack>
|
||||
),
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import toast from "../../../utils/toast.util";
|
||||
const showCreateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
smtpEnabled: boolean,
|
||||
getUsers: () => void
|
||||
getUsers: () => void,
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: "Create user",
|
||||
@@ -55,7 +55,7 @@ const Body = ({
|
||||
.string()
|
||||
.min(8, t("common.error.too-short", { length: 8 }))
|
||||
.optional(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ const Body = ({
|
||||
labelPosition="left"
|
||||
label={t("admin.users.modal.create.manual-password")}
|
||||
description={t(
|
||||
"admin.users.modal.create.manual-password.description"
|
||||
"admin.users.modal.create.manual-password.description",
|
||||
)}
|
||||
{...form.getInputProps("setPasswordManually", {
|
||||
type: "checkbox",
|
||||
|
||||
@@ -21,7 +21,7 @@ import toast from "../../../utils/toast.util";
|
||||
const showUpdateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
user: User,
|
||||
getUsers: () => void
|
||||
getUsers: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
@@ -53,7 +53,7 @@ const Body = ({
|
||||
username: yup
|
||||
.string()
|
||||
.min(3, t("common.error.too-short", { length: 3 })),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ const Body = ({
|
||||
password: yup
|
||||
.string()
|
||||
.min(8, t("common.error.too-short", { length: 8 })),
|
||||
})
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -115,8 +115,8 @@ const Body = ({
|
||||
})
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("admin.users.edit.update.notify.password.success")
|
||||
)
|
||||
t("admin.users.edit.update.notify.password.success"),
|
||||
),
|
||||
)
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
@@ -18,19 +20,47 @@ 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 useTranslate from "../../hooks/useTranslate.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
or: {
|
||||
"&:before": {
|
||||
content: "''",
|
||||
flex: 1,
|
||||
display: "block",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[3]
|
||||
: theme.colors.gray[4],
|
||||
},
|
||||
"&:after": {
|
||||
content: "''",
|
||||
flex: 1,
|
||||
display: "block",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[3]
|
||||
: theme.colors.gray[4],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
const { refreshUser } = useUser();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const [showTotp, setShowTotp] = React.useState(false);
|
||||
const [loginToken, setLoginToken] = React.useState("");
|
||||
const [oauth, setOAuth] = React.useState<string[]>([]);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||
@@ -44,7 +74,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
initialValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
totp: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -55,7 +84,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
.then(async (response) => {
|
||||
if (response.data["loginToken"]) {
|
||||
// Prompt the user to enter their totp code
|
||||
setShowTotp(true);
|
||||
showNotification({
|
||||
icon: <TbInfoCircle />,
|
||||
color: "blue",
|
||||
@@ -63,7 +91,11 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
title: t("signIn.notify.totp-required.title"),
|
||||
message: t("signIn.notify.totp-required.description"),
|
||||
});
|
||||
setLoginToken(response.data["loginToken"]);
|
||||
router.push(
|
||||
`/auth/totp/${
|
||||
response.data["loginToken"]
|
||||
}?redirect=${encodeURIComponent(redirectPath)}`,
|
||||
);
|
||||
} else {
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
@@ -72,25 +104,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
const signInTotp = (email: string, password: string, totp: string) => {
|
||||
authService
|
||||
.signInTotp(email, password, totp, loginToken)
|
||||
.then(async () => {
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.response?.data?.error == "share_password_required") {
|
||||
toast.axiosError(error);
|
||||
// Refresh the page to start over
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
toast.axiosError(error);
|
||||
form.setValues({ totp: "" });
|
||||
});
|
||||
const getAvailableOAuth = async () => {
|
||||
const oauth = await authService.getAvailableOAuth();
|
||||
setOAuth(oauth.data);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getAvailableOAuth().catch(toast.axiosError);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
@@ -107,9 +129,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (showTotp)
|
||||
signInTotp(values.emailOrUsername, values.password, values.totp);
|
||||
else signIn(values.emailOrUsername, values.password);
|
||||
signIn(values.emailOrUsername, values.password);
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
@@ -123,15 +143,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{showTotp && (
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label={t("account.modal.totp.code")}
|
||||
placeholder="******"
|
||||
mt="md"
|
||||
{...form.getInputProps("totp")}
|
||||
/>
|
||||
)}
|
||||
{config.get("smtp.enabled") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
@@ -143,6 +154,27 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
<FormattedMessage id="signin.button.submit" />
|
||||
</Button>
|
||||
</form>
|
||||
{oauth.length > 0 && (
|
||||
<Stack mt="xl">
|
||||
<Group align="center" className={classes.or}>
|
||||
<Text>{t("signIn.oauth.or")}</Text>
|
||||
</Group>
|
||||
<Group position="center">
|
||||
{oauth.map((provider) => (
|
||||
<Button
|
||||
key={provider}
|
||||
component="a"
|
||||
target="_blank"
|
||||
title={t(`signIn.oauth.${provider}`)}
|
||||
href={getOAuthUrl(config.get("general.appUrl"), provider)}
|
||||
variant="light"
|
||||
>
|
||||
{getOAuthIcon(provider)}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ const SignUpForm = () => {
|
||||
<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
|
||||
|
||||
84
frontend/src/components/auth/TotpForm.tsx
Normal file
84
frontend/src/components/auth/TotpForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PinInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useState } from "react";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { useRouter } from "next/router";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
function TotpForm({ redirectPath }: { redirectPath: string }) {
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
code: yup
|
||||
.string()
|
||||
.min(6, t("common.error.too-short", { length: 6 }))
|
||||
.required(t("common.error.field-required")),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
code: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.signInTotp(
|
||||
form.values.code,
|
||||
router.query.loginToken as string,
|
||||
);
|
||||
await refreshUser();
|
||||
await router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
toast.axiosError(e);
|
||||
form.setFieldError("code", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
<FormattedMessage id="totp.title" />
|
||||
</Title>
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group position="center">
|
||||
<PinInput
|
||||
length={6}
|
||||
oneTimeCode
|
||||
aria-label="One time code"
|
||||
autoFocus={true}
|
||||
onComplete={onSubmit}
|
||||
{...form.getInputProps("code")}
|
||||
/>
|
||||
<Button mt="md" type="submit" loading={loading}>
|
||||
{t("totp.button.signIn")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TotpForm;
|
||||
@@ -44,6 +44,7 @@ const FilePreview = ({
|
||||
href={`/api/shares/${shareId}/files/${fileId}?download=false`}
|
||||
>
|
||||
View original file
|
||||
{/* Add translation? */}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import CopyTextField from "../../upload/CopyTextField";
|
||||
const showCompletedReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
link: string,
|
||||
getReverseShares: () => void
|
||||
getReverseShares: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import moment from "moment";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||
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";
|
||||
@@ -23,14 +26,17 @@ import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
|
||||
const showCreateReverseShareModal = (
|
||||
modals: ModalsContextProps,
|
||||
showSendEmailNotificationOption: boolean,
|
||||
getReverseShares: () => void
|
||||
maxExpirationInHours: number,
|
||||
getReverseShares: () => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
title: "Create reverse share",
|
||||
title: t("account.reverseShares.modal.title"),
|
||||
children: (
|
||||
<Body
|
||||
showSendEmailNotificationOption={showSendEmailNotificationOption}
|
||||
getReverseShares={getReverseShares}
|
||||
maxExpirationInHours={maxExpirationInHours}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -39,9 +45,11 @@ const showCreateReverseShareModal = (
|
||||
const Body = ({
|
||||
getReverseShares,
|
||||
showSendEmailNotificationOption,
|
||||
maxExpirationInHours,
|
||||
}: {
|
||||
getReverseShares: () => void;
|
||||
showSendEmailNotificationOption: boolean;
|
||||
maxExpirationInHours: number;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
@@ -55,27 +63,48 @@ const Body = ({
|
||||
expiration_unit: "-days",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.onSubmit(async (values) => {
|
||||
const expirationDate = moment().add(
|
||||
form.values.expiration_num,
|
||||
form.values.expiration_unit.replace(
|
||||
"-",
|
||||
"",
|
||||
) as moment.unitOfTime.DurationConstructor,
|
||||
);
|
||||
if (
|
||||
maxExpirationInHours != 0 &&
|
||||
expirationDate.isAfter(moment().add(maxExpirationInHours, "hours"))
|
||||
) {
|
||||
form.setFieldError(
|
||||
"expiration_num",
|
||||
t("upload.modal.expires.error.too-long", {
|
||||
max: moment.duration(maxExpirationInHours, "hours").humanize(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
shareService
|
||||
.createReverseShare(
|
||||
values.expiration_num + values.expiration_unit,
|
||||
values.maxShareSize,
|
||||
values.maxUseCount,
|
||||
values.sendEmailNotification,
|
||||
)
|
||||
.then(({ link }) => {
|
||||
modals.closeAll();
|
||||
showCompletedReverseShareModal(modals, link, getReverseShares);
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
});
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
shareService
|
||||
.createReverseShare(
|
||||
values.expiration_num + values.expiration_unit,
|
||||
values.maxShareSize,
|
||||
values.maxUseCount,
|
||||
values.sendEmailNotification
|
||||
)
|
||||
.then(({ link }) => {
|
||||
modals.closeAll();
|
||||
showCompletedReverseShareModal(modals, link, getReverseShares);
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Stack align="stretch">
|
||||
<div>
|
||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||
<Grid align={form.errors.expiration_num ? "center" : "flex-end"}>
|
||||
<Col xs={6}>
|
||||
<NumberInput
|
||||
min={1}
|
||||
@@ -150,7 +179,7 @@ const Body = ({
|
||||
expiresOn: t("account.reverseShare.expires-on"),
|
||||
neverExpires: t("account.reverseShare.never-expires"),
|
||||
},
|
||||
form
|
||||
form,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -174,7 +203,7 @@ const Body = ({
|
||||
labelPosition="left"
|
||||
label={t("account.reverseShares.modal.send-email")}
|
||||
description={t(
|
||||
"account.reverseShares.modal.send-email.description"
|
||||
"account.reverseShares.modal.send-email.description",
|
||||
)}
|
||||
{...form.getInputProps("sendEmailNotification", {
|
||||
type: "checkbox",
|
||||
|
||||
@@ -6,7 +6,7 @@ import FilePreview from "../FilePreview";
|
||||
const showFilePreviewModal = (
|
||||
shareId: string,
|
||||
file: FileMetaData,
|
||||
modals: ModalsContextProps
|
||||
modals: ModalsContextProps,
|
||||
) => {
|
||||
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
|
||||
return modals.openModal({
|
||||
|
||||
@@ -8,7 +8,7 @@ import useTranslate, {
|
||||
|
||||
const showEnterPasswordModal = (
|
||||
modals: ModalsContextProps,
|
||||
submitCallback: (password: string) => Promise<void>
|
||||
submitCallback: (password: string) => Promise<void>,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
return modals.openModal({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FormattedMessage } from "react-intl";
|
||||
const showErrorModal = (
|
||||
modals: ModalsContextProps,
|
||||
title: string,
|
||||
text: string
|
||||
text: string,
|
||||
) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ function CopyTextField(props: { link: string }) {
|
||||
const [checkState, setCheckState] = useState(false);
|
||||
const [textClicked, setTextClicked] = useState(false);
|
||||
const timerRef = useRef<number | ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
const copyLink = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { FormattedMessage } from "react-intl";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
@@ -33,15 +33,15 @@ const useStyles = createStyles((theme) => ({
|
||||
}));
|
||||
|
||||
const Dropzone = ({
|
||||
title,
|
||||
isUploading,
|
||||
maxShareSize,
|
||||
files,
|
||||
setFiles,
|
||||
showCreateUploadModalCallback,
|
||||
}: {
|
||||
title?: string;
|
||||
isUploading: boolean;
|
||||
maxShareSize: number;
|
||||
files: FileUpload[];
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
|
||||
@@ -55,24 +55,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(
|
||||
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}
|
||||
@@ -83,7 +80,7 @@ const Dropzone = ({
|
||||
<TbCloudUpload size={50} />
|
||||
</Group>
|
||||
<Text align="center" weight={700} size="lg" mt="xl">
|
||||
<FormattedMessage id="upload.dropzone.title" />
|
||||
{title || <FormattedMessage id="upload.dropzone.title" />}
|
||||
</Text>
|
||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||
<FormattedMessage
|
||||
|
||||
238
frontend/src/components/upload/EditableUpload.tsx
Normal file
238
frontend/src/components/upload/EditableUpload.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { cleanNotifications } from "@mantine/notifications";
|
||||
import { AxiosError } from "axios";
|
||||
import pLimit from "p-limit";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Dropzone from "../../components/upload/Dropzone";
|
||||
import FileList from "../../components/upload/FileList";
|
||||
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const promiseLimit = pLimit(3);
|
||||
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||
let errorToastShown = false;
|
||||
|
||||
const EditableUpload = ({
|
||||
maxShareSize,
|
||||
shareId,
|
||||
files: savedFiles = [],
|
||||
}: {
|
||||
maxShareSize?: number;
|
||||
isReverseShare?: boolean;
|
||||
shareId: string;
|
||||
files?: FileMetaData[];
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [existingFiles, setExistingFiles] =
|
||||
useState<Array<FileMetaData & { deleted?: boolean }>>(savedFiles);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<FileUpload[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const existingAndUploadedFiles: FileListItem[] = useMemo(
|
||||
() => [...uploadingFiles, ...existingFiles],
|
||||
[existingFiles, uploadingFiles],
|
||||
);
|
||||
const dirty = useMemo(() => {
|
||||
return (
|
||||
existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length
|
||||
);
|
||||
}, [existingFiles, uploadingFiles]);
|
||||
|
||||
const setFiles = (files: FileListItem[]) => {
|
||||
const _uploadFiles = files.filter(
|
||||
(file) => "uploadingProgress" in file,
|
||||
) as FileUpload[];
|
||||
const _existingFiles = files.filter(
|
||||
(file) => !("uploadingProgress" in file),
|
||||
) as FileMetaData[];
|
||||
|
||||
setUploadingFiles(_uploadFiles);
|
||||
setExistingFiles(_existingFiles);
|
||||
};
|
||||
|
||||
maxShareSize ??= parseInt(config.get("share.maxSize"));
|
||||
|
||||
const uploadFiles = async (files: FileUpload[]) => {
|
||||
const fileUploadPromises = files.map(async (file, fileIndex) =>
|
||||
// Limit the number of concurrent uploads to 3
|
||||
promiseLimit(async () => {
|
||||
let fileId: string;
|
||||
|
||||
const setFileProgress = (progress: number) => {
|
||||
setUploadingFiles((files) =>
|
||||
files.map((file, callbackIndex) => {
|
||||
if (fileIndex == callbackIndex) {
|
||||
file.uploadingProgress = progress;
|
||||
}
|
||||
return file;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
setFileProgress(1);
|
||||
|
||||
let chunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
// If the file is 0 bytes, we still need to upload 1 chunk
|
||||
if (chunks == 0) chunks++;
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
|
||||
const from = chunkIndex * chunkSize;
|
||||
const to = from + chunkSize;
|
||||
const blob = file.slice(from, to);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) =>
|
||||
await shareService
|
||||
.uploadFile(
|
||||
shareId,
|
||||
event,
|
||||
{
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
},
|
||||
chunkIndex,
|
||||
chunks,
|
||||
)
|
||||
.then((response) => {
|
||||
fileId = response.id;
|
||||
resolve(response);
|
||||
})
|
||||
.catch(reject);
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
setFileProgress(((chunkIndex + 1) / chunks) * 100);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof AxiosError &&
|
||||
e.response?.data.error == "unexpected_chunk_index"
|
||||
) {
|
||||
// Retry with the expected chunk index
|
||||
chunkIndex = e.response!.data!.expectedChunkIndex - 1;
|
||||
continue;
|
||||
} else {
|
||||
setFileProgress(-1);
|
||||
// Retry after 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
chunkIndex = -1;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(fileUploadPromises);
|
||||
};
|
||||
|
||||
const removeFiles = async () => {
|
||||
const removedFiles = existingFiles.filter((file) => !!file.deleted);
|
||||
|
||||
if (removedFiles.length > 0) {
|
||||
await Promise.all(
|
||||
removedFiles.map(async (file) => {
|
||||
await shareService.removeFile(shareId, file.id);
|
||||
}),
|
||||
);
|
||||
|
||||
setExistingFiles(existingFiles.filter((file) => !file.deleted));
|
||||
}
|
||||
};
|
||||
|
||||
const revertComplete = async () => {
|
||||
await shareService.revertComplete(shareId).then();
|
||||
};
|
||||
|
||||
const completeShare = async () => {
|
||||
return await shareService.completeShare(shareId);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
await revertComplete();
|
||||
await uploadFiles(uploadingFiles);
|
||||
|
||||
const hasFailed = uploadingFiles.some(
|
||||
(file) => file.uploadingProgress == -1,
|
||||
);
|
||||
|
||||
if (!hasFailed) {
|
||||
await removeFiles();
|
||||
}
|
||||
|
||||
await completeShare();
|
||||
|
||||
if (!hasFailed) {
|
||||
toast.success(t("share.edit.notify.save-success"));
|
||||
router.back();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("share.edit.notify.generic-error"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const appendFiles = (appendingFiles: FileUpload[]) => {
|
||||
setUploadingFiles([...appendingFiles, ...uploadingFiles]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there are any files that failed to upload
|
||||
const fileErrorCount = uploadingFiles.filter(
|
||||
(file) => file.uploadingProgress == -1,
|
||||
).length;
|
||||
|
||||
if (fileErrorCount > 0) {
|
||||
if (!errorToastShown) {
|
||||
toast.error(
|
||||
t("upload.notify.count-failed", { count: fileErrorCount }),
|
||||
{
|
||||
withCloseButton: false,
|
||||
autoClose: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
errorToastShown = true;
|
||||
} else {
|
||||
cleanNotifications();
|
||||
errorToastShown = false;
|
||||
}
|
||||
}, [uploadingFiles]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group position="right" mb={20}>
|
||||
<Button loading={isUploading} disabled={!dirty} onClick={() => save()}>
|
||||
<FormattedMessage id="common.button.save" />
|
||||
</Button>
|
||||
</Group>
|
||||
<Dropzone
|
||||
title={t("share.edit.append-upload")}
|
||||
maxShareSize={maxShareSize}
|
||||
showCreateUploadModalCallback={appendFiles}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
{existingAndUploadedFiles.length > 0 && (
|
||||
<FileList files={existingAndUploadedFiles} setFiles={setFiles} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default EditableUpload;
|
||||
@@ -1,41 +1,106 @@
|
||||
import { ActionIcon, Table } from "@mantine/core";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbTrash } from "react-icons/tb";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { GrUndo } from "react-icons/gr";
|
||||
import { FileListItem } from "../../types/File.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const FileList = ({
|
||||
const FileListRow = ({
|
||||
file,
|
||||
onRemove,
|
||||
onRestore,
|
||||
}: {
|
||||
file: FileListItem;
|
||||
onRemove?: () => void;
|
||||
onRestore?: () => void;
|
||||
}) => {
|
||||
{
|
||||
const uploadable = "uploadingProgress" in file;
|
||||
const uploading = uploadable && file.uploadingProgress !== 0;
|
||||
const removable = uploadable
|
||||
? file.uploadingProgress === 0
|
||||
: onRemove && !file.deleted;
|
||||
const restorable = onRestore && !uploadable && !!file.deleted; // maybe undefined, force boolean
|
||||
const deleted = !uploadable && !!file.deleted;
|
||||
|
||||
return (
|
||||
<tr
|
||||
style={{
|
||||
color: deleted ? "rgba(120, 120, 120, 0.5)" : "inherit",
|
||||
textDecoration: deleted ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(+file.size)}</td>
|
||||
<td>
|
||||
{removable && (
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{uploading && (
|
||||
<UploadProgressIndicator progress={file.uploadingProgress} />
|
||||
)}
|
||||
{restorable && (
|
||||
<ActionIcon
|
||||
color="primary"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={onRestore}
|
||||
>
|
||||
<GrUndo />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const FileList = <T extends FileListItem = FileListItem>({
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
files: FileUpload[];
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
files: T[];
|
||||
setFiles: (files: T[]) => void;
|
||||
}) => {
|
||||
const remove = (index: number) => {
|
||||
files.splice(index, 1);
|
||||
const file = files[index];
|
||||
|
||||
if ("uploadingProgress" in file) {
|
||||
files.splice(index, 1);
|
||||
} else {
|
||||
files[index] = { ...file, deleted: true };
|
||||
}
|
||||
|
||||
setFiles([...files]);
|
||||
};
|
||||
|
||||
const restore = (index: number) => {
|
||||
const file = files[index];
|
||||
|
||||
if ("uploadingProgress" in file) {
|
||||
return;
|
||||
} else {
|
||||
files[index] = { ...file, deleted: false };
|
||||
}
|
||||
|
||||
setFiles([...files]);
|
||||
};
|
||||
|
||||
const rows = files.map((file, i) => (
|
||||
<tr key={i}>
|
||||
<td>{file.name}</td>
|
||||
<td>{byteToHumanSizeString(file.size)}</td>
|
||||
<td>
|
||||
{file.uploadingProgress == 0 ? (
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<UploadProgressIndicator progress={file.uploadingProgress} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<FileListRow
|
||||
key={i}
|
||||
file={file}
|
||||
onRemove={() => remove(i)}
|
||||
onRestore={() => restore(i)}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user