Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09213a295 | ||
|
|
fc116d65c0 | ||
|
|
76088cc76a | ||
|
|
16b697053a | ||
|
|
349bf475cc | ||
|
|
fccc4cbc02 | ||
|
|
f1b44f87fa | ||
|
|
02e41e2437 | ||
|
|
74e8956106 | ||
|
|
dc9ec429c6 | ||
|
|
653d72bcb9 | ||
|
|
a5bef5d4a4 | ||
|
|
c8ad2225e3 | ||
|
|
72c8081e7c | ||
|
|
f2d4895e50 | ||
|
|
54f591cd60 | ||
|
|
f836a0a3cd | ||
|
|
11174656e4 | ||
|
|
faea1abcc4 | ||
|
|
71658ad39d | ||
|
|
167f0f8c7a |
1
.github/workflows/close_inactive_issues.yml
vendored
1
.github/workflows/close_inactive_issues.yml
vendored
@@ -14,6 +14,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
days-before-issue-stale: 30
|
days-before-issue-stale: 30
|
||||||
days-before-issue-close: 14
|
days-before-issue-close: 14
|
||||||
|
exempt-issue-labels: "feature"
|
||||||
stale-issue-label: "stale"
|
stale-issue-label: "stale"
|
||||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
||||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||||
|
|||||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,3 +1,56 @@
|
|||||||
|
## [0.7.0](https://github.com/stonith404/pingvin-share/compare/v0.6.1...v0.7.0) (2023-01-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ClamAV to scan for malicious files ([76088cc](https://github.com/stonith404/pingvin-share/commit/76088cc76aedae709f06deaee2244efcf6a22bed))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* invalid github release link on admin page ([349bf47](https://github.com/stonith404/pingvin-share/commit/349bf475cc7fc1141dbd2a9bd2f63153c4d5b41b))
|
||||||
|
|
||||||
|
### [0.6.1](https://github.com/stonith404/pingvin-share/compare/v0.6.0...v0.6.1) (2023-01-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* delete all sessions if password was changed ([02e41e2](https://github.com/stonith404/pingvin-share/commit/02e41e243768de34de1bdc8833e83f60db530e55))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* shareUrl uses wrong origin ([f1b44f8](https://github.com/stonith404/pingvin-share/commit/f1b44f87fa64d3b21ca92c9068cb352d0ad51bc0))
|
||||||
|
* update password doesn't work ([74e8956](https://github.com/stonith404/pingvin-share/commit/74e895610642552c98c0015d0f8347735aaed457))
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/stonith404/pingvin-share/compare/v0.5.1...v0.6.0) (2023-01-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* chunk uploads ([#76](https://github.com/stonith404/pingvin-share/issues/76)) ([653d72b](https://github.com/stonith404/pingvin-share/commit/653d72bcb958268e2f23efae94cccb72faa745af))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* access token refreshes even it is still valid ([c8ad222](https://github.com/stonith404/pingvin-share/commit/c8ad2225e3c9ca79fea494d538b67797fbc7f6ae))
|
||||||
|
* error message typo ([72c8081](https://github.com/stonith404/pingvin-share/commit/72c8081e7c135ab1f600ed7e3d7a0bf03dabde34))
|
||||||
|
* migration for v0.5.1 ([f2d4895](https://github.com/stonith404/pingvin-share/commit/f2d4895e50d3da82cef68858752fb7f6293e7a20))
|
||||||
|
* refresh token expires after 1 day instead of 3 months ([a5bef5d](https://github.com/stonith404/pingvin-share/commit/a5bef5d4a4ae75447ca1f65259c5541edfc87dd8))
|
||||||
|
|
||||||
|
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
|
||||||
|
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
|
||||||
|
|
||||||
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
|
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ RUN npm run build && npm prune --production
|
|||||||
|
|
||||||
# Stage 5: Final image
|
# Stage 5: Final image
|
||||||
FROM node:18-slim AS runner
|
FROM node:18-slim AS runner
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=docker
|
||||||
RUN apt-get update && apt-get install -y openssl
|
RUN apt-get update && apt-get install -y openssl
|
||||||
|
|
||||||
WORKDIR /opt/app/frontend
|
WORKDIR /opt/app/frontend
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -4,13 +4,12 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- Spin up your instance within 2 minutes
|
|
||||||
- Create a share with files that you can access with a link
|
- Create a share with files that you can access with a link
|
||||||
- No file size limit, only your disk will be your limit
|
- No file size limit, only your disk will be your limit
|
||||||
- Set a share expiration
|
- Set a share expiration
|
||||||
- Optionally secure your share with a visitor limit and a password
|
- Optionally secure your share with a visitor limit and a password
|
||||||
- Email recepients
|
- Email recepients
|
||||||
- Light & dark mode
|
- ClamAV integration
|
||||||
|
|
||||||
## 🐧 Get to know Pingvin Share
|
## 🐧 Get to know Pingvin Share
|
||||||
|
|
||||||
@@ -30,6 +29,18 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
|
|||||||
|
|
||||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
#### ClamAV
|
||||||
|
|
||||||
|
With ClamAV the shares get scanned for malicious files and get removed if any found.
|
||||||
|
|
||||||
|
1. Add the ClamAV container to the Docker Compose stack (see `docker-compose.yml`) and start the container.
|
||||||
|
2. As soon as the ClamAV container is ready (when ClamAV logs "socket found, clamd started"), restart the Pingvin Share container with `docker compose restart pingvin-share`
|
||||||
|
3. The Pingvin Share logs should now log "ClamAV is active"
|
||||||
|
|
||||||
|
Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements).
|
||||||
|
|
||||||
### Additional resources
|
### Additional resources
|
||||||
|
|
||||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
|||||||
1469
backend/package-lock.json
generated
1469
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/system/newman-system-tests.json"
|
"test:system": "prisma migrate reset -f && nest start & wait-on http://localhost:8080/api/configs && newman run ./test/newman-system-tests.json"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
@@ -16,59 +16,62 @@
|
|||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
"@nestjs/core": "^9.2.1",
|
"@nestjs/core": "^9.2.1",
|
||||||
"@nestjs/jwt": "^9.0.0",
|
"@nestjs/jwt": "^10.0.1",
|
||||||
"@nestjs/mapped-types": "^1.2.0",
|
"@nestjs/mapped-types": "^1.2.0",
|
||||||
"@nestjs/passport": "^9.0.0",
|
"@nestjs/passport": "^9.0.0",
|
||||||
"@nestjs/platform-express": "^9.2.1",
|
"@nestjs/platform-express": "^9.2.1",
|
||||||
"@nestjs/schedule": "^2.1.0",
|
"@nestjs/schedule": "^2.1.0",
|
||||||
"@nestjs/throttler": "^3.1.0",
|
"@nestjs/throttler": "^3.1.0",
|
||||||
"@prisma/client": "^4.7.1",
|
"@prisma/client": "^4.8.1",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"argon2": "^0.30.2",
|
"argon2": "^0.30.3",
|
||||||
|
"body-parser": "^1.20.1",
|
||||||
|
"clamscan": "^2.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"multer": "^1.4.5-lts.1",
|
"nodemailer": "^6.9.0",
|
||||||
"nodemailer": "^6.8.0",
|
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"qrcode-svg": "^1.1.0",
|
"qrcode-svg": "^1.1.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^4.0.4",
|
||||||
"rxjs": "^7.6.0",
|
"rxjs": "^7.8.0",
|
||||||
"ts-node": "^10.9.1"
|
"ts-node": "^10.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.1.5",
|
"@nestjs/cli": "^9.1.8",
|
||||||
"@nestjs/schematics": "^9.0.3",
|
"@nestjs/schematics": "^9.0.4",
|
||||||
"@nestjs/testing": "^9.2.1",
|
"@nestjs/testing": "^9.2.1",
|
||||||
"@types/archiver": "^5.3.1",
|
"@types/archiver": "^5.3.1",
|
||||||
|
"@types/clamscan": "^2.0.4",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.14",
|
"@types/express": "^4.17.15",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/node": "^18.11.18",
|
||||||
"@types/node": "^18.11.10",
|
"@types/nodemailer": "^6.4.7",
|
||||||
"@types/nodemailer": "^6.4.6",
|
"@types/passport-jwt": "^3.0.8",
|
||||||
"@types/passport-jwt": "^3.0.7",
|
|
||||||
"@types/qrcode-svg": "^1.1.1",
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||||
"@typescript-eslint/parser": "^5.45.0",
|
"@typescript-eslint/parser": "^5.48.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.29.0",
|
"eslint": "^8.31.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"newman": "^5.3.2",
|
"newman": "^5.3.2",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.2",
|
||||||
"prisma": "^4.7.1",
|
"prisma": "^4.8.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.4.2",
|
"ts-loader": "^9.4.2",
|
||||||
"tsconfig-paths": "4.1.1",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "^4.9.3",
|
"typescript": "^4.9.4",
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^7.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
DROP TABLE "RefreshToken";
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;
|
||||||
@@ -27,7 +27,8 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
token String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
token String @unique @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
@@ -51,11 +52,12 @@ model Share {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
uploadLocked Boolean @default(false)
|
uploadLocked Boolean @default(false)
|
||||||
isZipReady Boolean @default(false)
|
isZipReady Boolean @default(false)
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
expiration DateTime
|
expiration DateTime
|
||||||
description String?
|
description String?
|
||||||
|
removedReason String?
|
||||||
|
|
||||||
creatorId String?
|
creatorId String?
|
||||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "MAX_FILE_SIZE",
|
key: "MAX_SHARE_SIZE",
|
||||||
description: "Maximum file size in bytes",
|
description: "Maximum share size in bytes",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "1000000000",
|
value: "1073741824",
|
||||||
category: "share",
|
category: "share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { HttpException, HttpStatus, Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
|
||||||
import { MulterModule } from "@nestjs/platform-express";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { ThrottlerModule } from "@nestjs/throttler";
|
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { Request } from "express";
|
|
||||||
import { ConfigModule } from "./config/config.module";
|
import { ConfigModule } from "./config/config.module";
|
||||||
import { ConfigService } from "./config/config.service";
|
|
||||||
import { EmailModule } from "./email/email.module";
|
import { EmailModule } from "./email/email.module";
|
||||||
import { FileModule } from "./file/file.module";
|
import { FileModule } from "./file/file.module";
|
||||||
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { ShareModule } from "./share/share.module";
|
import { ShareModule } from "./share/share.module";
|
||||||
import { UserModule } from "./user/user.module";
|
import { UserModule } from "./user/user.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { ClamscanModule } from "./clamscan/clamscan.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,29 +24,18 @@ import { JobsModule } from "./jobs/jobs.module";
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
MulterModule.registerAsync({
|
|
||||||
useFactory: (config: ConfigService) => ({
|
|
||||||
fileFilter: (req: Request, file, cb) => {
|
|
||||||
const MAX_FILE_SIZE = config.get("MAX_FILE_SIZE");
|
|
||||||
const requestFileSize = parseInt(req.headers["content-length"]);
|
|
||||||
const isValidFileSize = requestFileSize <= MAX_FILE_SIZE;
|
|
||||||
cb(
|
|
||||||
!isValidFileSize &&
|
|
||||||
new HttpException(
|
|
||||||
`File must be smaller than ${MAX_FILE_SIZE} bytes`,
|
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE
|
|
||||||
),
|
|
||||||
isValidFileSize
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
inject: [ConfigService],
|
|
||||||
}),
|
|
||||||
ThrottlerModule.forRoot({
|
ThrottlerModule.forRoot({
|
||||||
ttl: 60,
|
ttl: 60,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
ClamscanModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { Request, Response } from "express";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthTotpService } from "./authTotp.service";
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
@@ -17,7 +21,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
|||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
import { TokenDTO } from "./dto/token.dto";
|
||||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||||
import { JwtGuard } from "./guard/jwt.guard";
|
import { JwtGuard } from "./guard/jwt.guard";
|
||||||
@@ -32,39 +36,105 @@ export class AuthController {
|
|||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
async signUp(@Body() dto: AuthRegisterDTO) {
|
async signUp(
|
||||||
|
@Body() dto: AuthRegisterDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
return this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
signIn(@Body() dto: AuthSignInDTO) {
|
async signIn(
|
||||||
return this.authService.signIn(dto);
|
@Body() dto: AuthSignInDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
|
if (result.accessToken && result.refreshToken) {
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
signInTotp(@Body() dto: AuthSignInTotpDTO) {
|
async signInTotp(
|
||||||
return this.authTotpService.signInTotp(dto);
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.refreshToken,
|
||||||
|
result.accessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("password")
|
@Patch("password")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
|
async updatePassword(
|
||||||
await this.authService.updatePassword(user, dto.oldPassword, dto.password);
|
@GetUser() user: User,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
@Body() dto: UpdatePasswordDTO
|
||||||
|
) {
|
||||||
|
const result = await this.authService.updatePassword(
|
||||||
|
user,
|
||||||
|
dto.oldPassword,
|
||||||
|
dto.password
|
||||||
|
);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(response, result.refreshToken);
|
||||||
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("token")
|
@Post("token")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
async refreshAccessToken(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
body.refreshToken
|
request.cookies.refresh_token
|
||||||
);
|
);
|
||||||
return { accessToken };
|
response.cookie("access_token", accessToken);
|
||||||
|
return new TokenDTO().from({ accessToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("signOut")
|
||||||
|
async signOut(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
|
response.cookie("refresh_token", "", {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("totp/enable")
|
@Post("totp/enable")
|
||||||
@@ -85,4 +155,20 @@ export class AuthController {
|
|||||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
// 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);
|
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addTokensToResponse(
|
||||||
|
response: Response,
|
||||||
|
refreshToken?: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
if (accessToken) response.cookie("access_token", accessToken);
|
||||||
|
if (refreshToken)
|
||||||
|
response.cookie("refresh_token", refreshToken, {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
user.id
|
||||||
|
);
|
||||||
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -71,8 +73,10 @@ export class AuthService {
|
|||||||
return { loginToken };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
user.id
|
||||||
|
);
|
||||||
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
@@ -83,17 +87,24 @@ export class AuthService {
|
|||||||
|
|
||||||
const hash = await argon.hash(newPassword);
|
const hash = await argon.hash(newPassword);
|
||||||
|
|
||||||
this.prisma.user.update({
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { password: hash },
|
data: { password: hash },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.createRefreshToken(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccessToken(user: User) {
|
async createAccessToken(user: User, refreshTokenId: string) {
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
refreshTokenId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
@@ -102,6 +113,19 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signOut(accessToken: string) {
|
||||||
|
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
||||||
|
refreshTokenId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.refreshToken
|
||||||
|
.delete({ where: { id: refreshTokenId } })
|
||||||
|
.catch((e) => {
|
||||||
|
// Ignore error if refresh token doesn't exist
|
||||||
|
if (e.code != "P2025") throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string) {
|
async refreshAccessToken(refreshToken: string) {
|
||||||
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
||||||
where: { token: refreshToken },
|
where: { token: refreshToken },
|
||||||
@@ -111,17 +135,18 @@ export class AuthService {
|
|||||||
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
return this.createAccessToken(refreshTokenMetaData.user);
|
return this.createAccessToken(
|
||||||
|
refreshTokenMetaData.user,
|
||||||
|
refreshTokenMetaData.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string) {
|
||||||
const refreshToken = (
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
await this.prisma.refreshToken.create({
|
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
||||||
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
});
|
||||||
})
|
|
||||||
).token;
|
|
||||||
|
|
||||||
return refreshToken;
|
return { refreshTokenId: id, refreshToken: token };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLoginToken(userId: string) {
|
async createLoginToken(userId: string) {
|
||||||
|
|||||||
@@ -71,8 +71,12 @@ export class AuthTotpService {
|
|||||||
data: { used: true },
|
data: { used: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = await this.authService.createAccessToken(user);
|
const { refreshToken, refreshTokenId } =
|
||||||
const refreshToken = await this.authService.createRefreshToken(user.id);
|
await this.authService.createRefreshToken(user.id);
|
||||||
|
const accessToken = await this.authService.createAccessToken(
|
||||||
|
user,
|
||||||
|
refreshTokenId
|
||||||
|
);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { IsNotEmpty } from "class-validator";
|
|
||||||
|
|
||||||
export class RefreshAccessTokenDTO {
|
|
||||||
@IsNotEmpty()
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
15
backend/src/auth/dto/token.dto.ts
Normal file
15
backend/src/auth/dto/token.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Expose, plainToClass } from "class-transformer";
|
||||||
|
|
||||||
|
export class TokenDTO {
|
||||||
|
@Expose()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
refreshToken: string;
|
||||||
|
|
||||||
|
from(partial: Partial<TokenDTO>) {
|
||||||
|
return plainToClass(TokenDTO, partial, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { Request } from "express";
|
||||||
|
import { Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
@@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||||
config.get("JWT_SECRET");
|
config.get("JWT_SECRET");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
secretOrKey: config.get("JWT_SECRET"),
|
secretOrKey: config.get("JWT_SECRET"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static extractJWT(req: Request) {
|
||||||
|
if (!req.cookies.access_token) return null;
|
||||||
|
return req.cookies.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
async validate(payload: { sub: string }) {
|
async validate(payload: { sub: string }) {
|
||||||
const user: User = await this.prisma.user.findUnique({
|
const user: User = await this.prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
|||||||
10
backend/src/clamscan/clamscan.module.ts
Normal file
10
backend/src/clamscan/clamscan.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
|
import { FileModule } from "src/file/file.module";
|
||||||
|
import { ClamScanService } from "./clamscan.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => FileModule)],
|
||||||
|
providers: [ClamScanService],
|
||||||
|
exports: [ClamScanService],
|
||||||
|
})
|
||||||
|
export class ClamscanModule {}
|
||||||
86
backend/src/clamscan/clamscan.service.ts
Normal file
86
backend/src/clamscan/clamscan.service.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import * as NodeClam from "clamscan";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { FileService } from "src/file/file.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
const clamscanConfig = {
|
||||||
|
clamdscan: {
|
||||||
|
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
|
||||||
|
port: 3310,
|
||||||
|
localFallback: false,
|
||||||
|
},
|
||||||
|
preference: "clamdscan",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClamScanService {
|
||||||
|
constructor(
|
||||||
|
private fileService: FileService,
|
||||||
|
private prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private ClamScan: Promise<NodeClam | null> = new NodeClam()
|
||||||
|
.init(clamscanConfig)
|
||||||
|
.then((res) => {
|
||||||
|
console.log("ClamAV is active");
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log("ClamAV is not active");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async check(shareId: string) {
|
||||||
|
const clamScan = await this.ClamScan;
|
||||||
|
|
||||||
|
if (!clamScan) return [];
|
||||||
|
|
||||||
|
const infectedFiles = [];
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(`./data/uploads/shares/${shareId}`)
|
||||||
|
.filter((file) => file != "archive.zip");
|
||||||
|
|
||||||
|
for (const fileId of files) {
|
||||||
|
const { isInfected } = await clamScan
|
||||||
|
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
|
||||||
|
.catch(() => {
|
||||||
|
console.log("ClamAV is not active");
|
||||||
|
return { isInfected: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = (
|
||||||
|
await this.prisma.file.findUnique({ where: { id: fileId } })
|
||||||
|
).name;
|
||||||
|
|
||||||
|
if (isInfected) {
|
||||||
|
infectedFiles.push({ id: fileId, name: fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return infectedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndRemove(shareId: string) {
|
||||||
|
const infectedFiles = await this.check(shareId);
|
||||||
|
|
||||||
|
if (infectedFiles.length > 0) {
|
||||||
|
await this.fileService.deleteAllFiles(shareId);
|
||||||
|
await this.prisma.file.deleteMany({ where: { shareId } });
|
||||||
|
|
||||||
|
const fileNames = infectedFiles.map((file) => file.name).join(", ");
|
||||||
|
|
||||||
|
await this.prisma.share.update({
|
||||||
|
where: { id: shareId },
|
||||||
|
data: {
|
||||||
|
removedReason: `Your share got removed because the file(s) ${fileNames} are malicious.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,15 +7,17 @@ import { ConfigService } from "src/config/config.service";
|
|||||||
export class EmailService {
|
export class EmailService {
|
||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
getTransporter() {
|
||||||
host: this.config.get("SMTP_HOST"),
|
return nodemailer.createTransport({
|
||||||
port: parseInt(this.config.get("SMTP_PORT")),
|
host: this.config.get("SMTP_HOST"),
|
||||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
port: parseInt(this.config.get("SMTP_PORT")),
|
||||||
auth: {
|
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||||
user: this.config.get("SMTP_USERNAME"),
|
auth: {
|
||||||
pass: this.config.get("SMTP_PASSWORD"),
|
user: this.config.get("SMTP_USERNAME"),
|
||||||
},
|
pass: this.config.get("SMTP_PASSWORD"),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||||
@@ -23,7 +25,7 @@ export class EmailService {
|
|||||||
|
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||||
|
|
||||||
await this.transporter.sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: this.config.get("EMAIL_SUBJECT"),
|
subject: this.config.get("EMAIL_SUBJECT"),
|
||||||
@@ -36,7 +38,7 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendTestMail(recipientEmail: string) {
|
||||||
await this.transporter.sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Test email",
|
subject: "Test email",
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
UploadedFile,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import * as contentDisposition from "content-disposition";
|
import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
||||||
import { ShareDTO } from "src/share/dto/share.dto";
|
|
||||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
@@ -24,22 +23,24 @@ export class FileController {
|
|||||||
constructor(private fileService: FileService) {}
|
constructor(private fileService: FileService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@SkipThrottle()
|
||||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
@UseGuards(JwtGuard, ShareOwnerGuard)
|
||||||
@UseInterceptors(
|
|
||||||
FileInterceptor("file", {
|
|
||||||
dest: "./data/uploads/_temp/",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
async create(
|
async create(
|
||||||
@UploadedFile()
|
@Query() query: any,
|
||||||
file: Express.Multer.File,
|
|
||||||
|
@Body() body: string,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
) {
|
) {
|
||||||
// Fixes file names with special characters
|
const { id, name, chunkIndex, totalChunks } = query;
|
||||||
file.originalname = Buffer.from(file.originalname, "latin1").toString(
|
|
||||||
"utf8"
|
const data = body.toString().split(",")[1];
|
||||||
|
|
||||||
|
return await this.fileService.create(
|
||||||
|
data,
|
||||||
|
{ index: parseInt(chunkIndex), total: parseInt(totalChunks) },
|
||||||
|
{ id, name },
|
||||||
|
shareId
|
||||||
);
|
);
|
||||||
return new ShareDTO().from(await this.fileService.create(file, shareId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId/download")
|
@Get(":fileId/download")
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
import { ShareModule } from "src/share/share.module";
|
import { ShareModule } from "src/share/share.module";
|
||||||
import { FileController } from "./file.controller";
|
import { FileController } from "./file.controller";
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), ShareModule],
|
imports: [JwtModule.register({}), ShareModule],
|
||||||
controllers: [FileController],
|
controllers: [FileController],
|
||||||
providers: [FileService, FileValidationPipe],
|
providers: [FileService],
|
||||||
exports: [FileService],
|
exports: [FileService],
|
||||||
})
|
})
|
||||||
export class FileModule {}
|
export class FileModule {}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { randomUUID } from "crypto";
|
import * as crypto from "crypto";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as mime from "mime-types";
|
import * as mime from "mime-types";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
@@ -18,32 +20,85 @@ export class FileService {
|
|||||||
private config: ConfigService
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(file: Express.Multer.File, shareId: string) {
|
async create(
|
||||||
|
data: string,
|
||||||
|
chunk: { index: number; total: number },
|
||||||
|
file: { id?: string; name: string },
|
||||||
|
shareId: string
|
||||||
|
) {
|
||||||
|
if (!file.id) file.id = crypto.randomUUID();
|
||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
|
include: { files: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (share.uploadLocked)
|
if (share.uploadLocked)
|
||||||
throw new BadRequestException("Share is already completed");
|
throw new BadRequestException("Share is already completed");
|
||||||
|
|
||||||
const fileId = randomUUID();
|
let diskFileSize: number;
|
||||||
|
try {
|
||||||
|
diskFileSize = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
|
||||||
|
).size;
|
||||||
|
} catch {
|
||||||
|
diskFileSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
await fs.promises.mkdir(`./data/uploads/shares/${shareId}`, {
|
// If the sent chunk index and the expected chunk index doesn't match throw an error
|
||||||
recursive: true,
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
});
|
const expectedChunkIndex = Math.ceil(diskFileSize / chunkSize);
|
||||||
fs.promises.rename(
|
|
||||||
`./data/uploads/_temp/${file.filename}`,
|
if (expectedChunkIndex != chunk.index)
|
||||||
`./data/uploads/shares/${shareId}/${fileId}`
|
throw new BadRequestException({
|
||||||
|
message: "Unexpected chunk received",
|
||||||
|
error: "unexpected_chunk_index",
|
||||||
|
expectedChunkIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(data, "base64");
|
||||||
|
|
||||||
|
// Check if share size limit is exceeded
|
||||||
|
const fileSizeSum = share.files.reduce(
|
||||||
|
(n, { size }) => n + parseInt(size),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.prisma.file.create({
|
if (
|
||||||
data: {
|
fileSizeSum + diskFileSize + buffer.byteLength >
|
||||||
id: fileId,
|
this.config.get("MAX_SHARE_SIZE")
|
||||||
name: file.originalname,
|
) {
|
||||||
size: file.size.toString(),
|
throw new HttpException(
|
||||||
share: { connect: { id: shareId } },
|
"Max share size exceeded",
|
||||||
},
|
HttpStatus.PAYLOAD_TOO_LARGE
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.appendFileSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastChunk = chunk.index == chunk.total - 1;
|
||||||
|
if (isLastChunk) {
|
||||||
|
fs.renameSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}`
|
||||||
|
);
|
||||||
|
const fileSize = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareId}/${file.id}`
|
||||||
|
).size;
|
||||||
|
await this.prisma.file.create({
|
||||||
|
data: {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
size: fileSize.toString(),
|
||||||
|
share: { connect: { id: shareId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(shareId: string, fileId: string) {
|
async get(shareId: string, fileId: string) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import {
|
|
||||||
ArgumentMetadata,
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
PipeTransform,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FileValidationPipe implements PipeTransform {
|
|
||||||
constructor(private config: ConfigService) {}
|
|
||||||
async transform(value: any, metadata: ArgumentMetadata) {
|
|
||||||
if (value.size > this.config.get("MAX_FILE_SIZE"))
|
|
||||||
throw new BadRequestException("File is ");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,18 +38,34 @@ export class JobsService {
|
|||||||
|
|
||||||
@Cron("0 0 * * *")
|
@Cron("0 0 * * *")
|
||||||
deleteTemporaryFiles() {
|
deleteTemporaryFiles() {
|
||||||
const files = fs.readdirSync("./data/uploads/_temp");
|
let filesDeleted = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
const shareDirectories = fs
|
||||||
const stats = fs.statSync(`./data/uploads/_temp/${file}`);
|
.readdirSync("./data/uploads/shares", { withFileTypes: true })
|
||||||
const isOlderThanOneDay = moment(stats.mtime)
|
.filter((dirent) => dirent.isDirectory())
|
||||||
.add(1, "day")
|
.map((dirent) => dirent.name);
|
||||||
.isBefore(moment());
|
|
||||||
|
|
||||||
if (isOlderThanOneDay) fs.rmSync(`./data/uploads/_temp/${file}`);
|
for (const shareDirectory of shareDirectories) {
|
||||||
|
const temporaryFiles = fs
|
||||||
|
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
|
||||||
|
.filter((file) => file.endsWith(".tmp-chunk"));
|
||||||
|
|
||||||
|
for (const file of temporaryFiles) {
|
||||||
|
const stats = fs.statSync(
|
||||||
|
`./data/uploads/shares/${shareDirectory}/${file}`
|
||||||
|
);
|
||||||
|
const isOlderThanOneDay = moment(stats.mtime)
|
||||||
|
.add(1, "day")
|
||||||
|
.isBefore(moment());
|
||||||
|
|
||||||
|
if (isOlderThanOneDay) {
|
||||||
|
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
|
||||||
|
filesDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`job: deleted ${files.length} temporary files`);
|
console.log(`job: deleted ${filesDeleted} temporary files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron("0 * * * *")
|
@Cron("0 * * * *")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import * as bodyParser from "body-parser";
|
||||||
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
@@ -9,6 +11,8 @@ async function bootstrap() {
|
|||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
|
app.use(bodyParser.raw({ type: "application/octet-stream", limit: "20mb" }));
|
||||||
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { ClamscanModule } from "src/clamscan/clamscan.module";
|
||||||
import { EmailModule } from "src/email/email.module";
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { FileModule } from "src/file/file.module";
|
import { FileModule } from "src/file/file.module";
|
||||||
import { ShareController } from "./share.controller";
|
import { ShareController } from "./share.controller";
|
||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
|
imports: [
|
||||||
|
JwtModule.register({}),
|
||||||
|
EmailModule,
|
||||||
|
ClamscanModule,
|
||||||
|
forwardRef(() => FileModule),
|
||||||
|
],
|
||||||
controllers: [ShareController],
|
controllers: [ShareController],
|
||||||
providers: [ShareService],
|
providers: [ShareService],
|
||||||
exports: [ShareService],
|
exports: [ShareService],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as archiver from "archiver";
|
|||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
|
import { ClamScanService } from "src/clamscan/clamscan.service";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
import { FileService } from "src/file/file.service";
|
import { FileService } from "src/file/file.service";
|
||||||
@@ -23,7 +24,8 @@ export class ShareService {
|
|||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private jwtService: JwtService
|
private jwtService: JwtService,
|
||||||
|
private clasmScanService: ClamScanService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(share: CreateShareDTO, user?: User) {
|
async create(share: CreateShareDTO, user?: User) {
|
||||||
@@ -56,6 +58,10 @@ export class ShareService {
|
|||||||
expirationDate = moment(0).toDate();
|
expirationDate = moment(0).toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
|
||||||
return await this.prisma.share.create({
|
return await this.prisma.share.create({
|
||||||
data: {
|
data: {
|
||||||
...share,
|
...share,
|
||||||
@@ -119,6 +125,9 @@ export class ShareService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if any file is malicious with ClamAV
|
||||||
|
this.clasmScanService.checkAndRemove(share.id);
|
||||||
|
|
||||||
return await this.prisma.share.update({
|
return await this.prisma.share.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { uploadLocked: true },
|
data: { uploadLocked: true },
|
||||||
@@ -153,7 +162,7 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
const share: any = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -161,10 +170,13 @@ export class ShareService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (share.removedReason)
|
||||||
|
throw new NotFoundException(share.removedReason, "share_removed");
|
||||||
|
|
||||||
if (!share || !share.uploadLocked)
|
if (!share || !share.uploadLocked)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
return share;
|
return share as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaData(id: string) {
|
async getMetaData(id: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a",
|
"_postman_id": "38c7001d-4868-484b-935a-84fd3b5e7cf6",
|
||||||
"name": "Pingvin Share Testing",
|
"name": "Pingvin Share Testing",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
"_exporter_id": "17822132"
|
"_exporter_id": "17822132"
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
"exec": [
|
"exec": [
|
||||||
"if(pm.response.to.have.status(201)){",
|
"if(pm.response.to.have.status(201)){",
|
||||||
" const token = pm.response.json()[\"accessToken\"]",
|
" const token = pm.response.json()[\"accessToken\"]",
|
||||||
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
|
|
||||||
"",
|
|
||||||
" // Get user id",
|
" // Get user id",
|
||||||
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
||||||
" const userId = jwtPayload[\"sub\"]",
|
" const userId = jwtPayload[\"sub\"]",
|
||||||
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
||||||
|
"",
|
||||||
|
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
|
||||||
"}",
|
"}",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
||||||
"});",
|
"});",
|
||||||
|
"",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@@ -477,28 +478,34 @@
|
|||||||
"pm.test(\"Response body correct\", () => {",
|
"pm.test(\"Response body correct\", () => {",
|
||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody).to.have.property(\"id\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
" pm.expect(responseBody.name).to.be.equal(\"test-file.txt\")",
|
||||||
|
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
||||||
"});"
|
"});"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disabledSystemHeaders": {
|
||||||
|
"content-type": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/octet-stream",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "formdata",
|
"mode": "raw",
|
||||||
"formdata": [
|
"raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files?name=test-file.txt&chunkIndex=0&totalChunks=1",
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
@@ -507,6 +514,20 @@
|
|||||||
":shareId",
|
":shareId",
|
||||||
"files"
|
"files"
|
||||||
],
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"value": "test-file.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "chunkIndex",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalChunks",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
"key": "shareId",
|
"key": "shareId",
|
||||||
@@ -530,29 +551,34 @@
|
|||||||
"",
|
"",
|
||||||
"pm.test(\"Response body correct\", () => {",
|
"pm.test(\"Response body correct\", () => {",
|
||||||
" const responseBody = pm.response.json();",
|
" const responseBody = pm.response.json();",
|
||||||
" pm.expect(responseBody).to.have.property(\"id\")",
|
" pm.expect(responseBody.name).to.be.equal(\"test-file2.txt\")",
|
||||||
" pm.expect(Object.keys(responseBody).length).be.equal(1)",
|
" pm.expect(Object.keys(responseBody).length).be.equal(2)",
|
||||||
"});"
|
"});"
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"protocolProfileBehavior": {
|
||||||
|
"disabledSystemHeaders": {
|
||||||
|
"content-type": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [],
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/octet-stream",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "formdata",
|
"mode": "raw",
|
||||||
"formdata": [
|
"raw": "data:application/octet-stream;base64,VGhpcyBpcyBhIHRlc3QgZmlsZWQgdXNlZCBmb3IgdXBsb2FkaW5nIGluIHRoZSBzeXN0ZW0gdGVzdC4="
|
||||||
{
|
|
||||||
"key": "file",
|
|
||||||
"type": "file",
|
|
||||||
"src": "./test/system/test-file.txt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{API_URL}}/shares/:shareId/files",
|
"raw": "{{API_URL}}/shares/:shareId/files?name=test-file2.txt&chunkIndex=0&totalChunks=1",
|
||||||
"host": [
|
"host": [
|
||||||
"{{API_URL}}"
|
"{{API_URL}}"
|
||||||
],
|
],
|
||||||
@@ -561,6 +587,20 @@
|
|||||||
":shareId",
|
":shareId",
|
||||||
"files"
|
"files"
|
||||||
],
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"value": "test-file2.txt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "chunkIndex",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalChunks",
|
||||||
|
"value": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
"key": "shareId",
|
"key": "shareId",
|
||||||
@@ -1556,23 +1596,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{USER_AUTH_TOKEN}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "prerequest",
|
"listen": "prerequest",
|
||||||
"script": {
|
"script": {
|
||||||
"type": "text/javascript",
|
"type": "text/javascript",
|
||||||
"exec": [
|
"exec": [
|
||||||
""
|
"pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1 +0,0 @@
|
|||||||
This is a test filed used for uploading in the system test.
|
|
||||||
7
docker-compose-dev.yml
Normal file
7
docker-compose-dev.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
clamav:
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 3310:3310
|
||||||
|
image: clamav/clamav
|
||||||
@@ -6,4 +6,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- "${PWD}/data:/opt/app/backend/data"
|
- "./data:/opt/app/backend/data"
|
||||||
|
# Optional: Add ClamAV (see README.md)
|
||||||
|
# ClamAV is currently only available for AMD64 see https://github.com/Cisco-Talos/clamav/issues/482
|
||||||
|
# clamav:
|
||||||
|
# restart: unless-stopped
|
||||||
|
# image: clamav/clamav
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const withPWA = require("next-pwa")({
|
const withPWA = require("next-pwa")({
|
||||||
dest: "public",
|
dest: "public",
|
||||||
disable: process.env.NODE_ENV == "development",
|
disable: process.env.NODE_ENV == "development",
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = withPWA({ output: "standalone" });
|
module.exports = withPWA({
|
||||||
|
output: "standalone", env: {
|
||||||
|
VERSION: version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
727
frontend/package-lock.json
generated
727
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -11,19 +11,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
"@mantine/core": "^5.9.2",
|
"@mantine/core": "^5.10.0",
|
||||||
"@mantine/dropzone": "^5.9.2",
|
"@mantine/dropzone": "^5.10.0",
|
||||||
"@mantine/form": "^5.9.2",
|
"@mantine/form": "^5.10.0",
|
||||||
"@mantine/hooks": "^5.9.2",
|
"@mantine/hooks": "^5.10.0",
|
||||||
"@mantine/modals": "^5.9.2",
|
"@mantine/modals": "^5.10.0",
|
||||||
"@mantine/next": "^5.9.2",
|
"@mantine/next": "^5.10.0",
|
||||||
"@mantine/notifications": "^5.9.2",
|
"@mantine/notifications": "^5.10.0",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.2",
|
||||||
"cookies-next": "^2.1.1",
|
"cookies-next": "^2.1.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jose": "^4.11.1",
|
"jose": "^4.11.2",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.0.6",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
"next-http-proxy-middleware": "^1.2.5",
|
"next-http-proxy-middleware": "^1.2.5",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
@@ -34,15 +34,15 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "18.11.10",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.0.10",
|
||||||
"axios": "^1.2.0",
|
"axios": "^1.2.2",
|
||||||
"eslint": "8.29.0",
|
"eslint": "8.31.0",
|
||||||
"eslint-config-next": "^13.0.6",
|
"eslint-config-next": "^13.1.2",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"prettier": "^2.8.0",
|
"prettier": "^2.8.2",
|
||||||
"tar": "^6.1.12",
|
"tar": "^6.1.13",
|
||||||
"typescript": "^4.9.3"
|
"typescript": "^4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Stack, TextInput } from "@mantine/core";
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||||
|
|
||||||
const showShareLinkModal = (modals: ModalsContextProps, shareId: string) => {
|
const showShareLinkModal = (
|
||||||
const link = `${window.location.origin}/share/${shareId}`;
|
modals: ModalsContextProps,
|
||||||
|
shareId: string,
|
||||||
|
appUrl: string
|
||||||
|
) => {
|
||||||
|
const link = `${appUrl}/share/${shareId}`;
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
title: "Share link",
|
title: "Share link",
|
||||||
children: (
|
children: (
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const AdminConfigInput = ({
|
|||||||
(configVariable.obscured ? (
|
(configVariable.obscured ? (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
|
||||||
{...form.getInputProps("stringValue")}
|
{...form.getInputProps("stringValue")}
|
||||||
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -115,9 +115,10 @@ const AdminConfigTable = () => {
|
|||||||
if (config.get("SETUP_FINISHED")) {
|
if (config.get("SETUP_FINISHED")) {
|
||||||
configService
|
configService
|
||||||
.updateMany(updatedConfigVariables)
|
.updateMany(updatedConfigVariables)
|
||||||
.then(() =>
|
.then(() => {
|
||||||
toast.success("Configurations updated successfully")
|
updatedConfigVariables = [];
|
||||||
)
|
toast.success("Configurations updated successfully");
|
||||||
|
})
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
} else {
|
} else {
|
||||||
configService
|
configService
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const ActionAvatar = () => {
|
|||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
authService.signOut();
|
await authService.signOut();
|
||||||
}}
|
}}
|
||||||
icon={<TbDoorExit size={14} />}
|
icon={<TbDoorExit size={14} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ const useStyles = createStyles((theme) => ({
|
|||||||
|
|
||||||
const Dropzone = ({
|
const Dropzone = ({
|
||||||
isUploading,
|
isUploading,
|
||||||
|
files,
|
||||||
setFiles,
|
setFiles,
|
||||||
}: {
|
}: {
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
|
files: FileUpload[];
|
||||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||||
}) => {
|
}) => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
@@ -45,18 +47,30 @@ const Dropzone = ({
|
|||||||
return (
|
return (
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<MantineDropzone
|
<MantineDropzone
|
||||||
maxSize={parseInt(config.get("MAX_FILE_SIZE"))}
|
|
||||||
onReject={(e) => {
|
onReject={(e) => {
|
||||||
toast.error(e[0].errors[0].message);
|
toast.error(e[0].errors[0].message);
|
||||||
}}
|
}}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
openRef={openRef as ForwardedRef<() => void>}
|
openRef={openRef as ForwardedRef<() => void>}
|
||||||
onDrop={(files) => {
|
onDrop={(newFiles: FileUpload[]) => {
|
||||||
const newFiles = files.map((file) => {
|
const fileSizeSum = [...newFiles, ...files].reduce(
|
||||||
(file as FileUpload).uploadingProgress = 0;
|
(n, { size }) => n + size,
|
||||||
return file as FileUpload;
|
0
|
||||||
});
|
);
|
||||||
setFiles(newFiles);
|
|
||||||
|
if (fileSizeSum > config.get("MAX_SHARE_SIZE")) {
|
||||||
|
toast.error(
|
||||||
|
`Your files exceed the maximum share size of ${byteStringToHumanSizeString(
|
||||||
|
config.get("MAX_SHARE_SIZE")
|
||||||
|
)}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newFiles = newFiles.map((newFile) => {
|
||||||
|
newFile.uploadingProgress = 0;
|
||||||
|
return newFile;
|
||||||
|
});
|
||||||
|
setFiles([...newFiles, ...files]);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={classes.dropzone}
|
className={classes.dropzone}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -71,7 +85,8 @@ const Dropzone = ({
|
|||||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||||
Drag'n'drop files here to start your share. We can accept
|
Drag'n'drop files here to start your share. We can accept
|
||||||
only files that are less than{" "}
|
only files that are less than{" "}
|
||||||
{byteStringToHumanSizeString(config.get("MAX_FILE_SIZE"))} in size.
|
{byteStringToHumanSizeString(config.get("MAX_SHARE_SIZE"))} in
|
||||||
|
total.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</MantineDropzone>
|
</MantineDropzone>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RingProgress } from "@mantine/core";
|
import { Loader, RingProgress } from "@mantine/core";
|
||||||
import { TbCircleCheck, TbCircleX } from "react-icons/tb";
|
import { TbCircleCheck } from "react-icons/tb";
|
||||||
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
||||||
if (progress > 0 && progress < 100) {
|
if (progress > 0 && progress < 100) {
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +12,7 @@ const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
|||||||
} else if (progress >= 100) {
|
} else if (progress >= 100) {
|
||||||
return <TbCircleCheck color="green" size={22} />;
|
return <TbCircleCheck color="green" size={22} />;
|
||||||
} else {
|
} else {
|
||||||
return <TbCircleX color="red" size={22} />;
|
return <Loader color="red" size={19} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { TbCopy } from "react-icons/tb";
|
|||||||
import { Share } from "../../../types/share.type";
|
import { Share } from "../../../types/share.type";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
const showCompletedUploadModal = (
|
||||||
|
modals: ModalsContextProps,
|
||||||
|
share: Share,
|
||||||
|
appUrl: string
|
||||||
|
) => {
|
||||||
return modals.openModal({
|
return modals.openModal({
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
@@ -25,18 +29,20 @@ const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
|
|||||||
<Title order={4}>Share ready</Title>
|
<Title order={4}>Share ready</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
children: <Body share={share} />,
|
children: <Body share={share} appUrl={appUrl} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body = ({ share }: { share: Share }) => {
|
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const link = `${window.location.origin}/share/${share.id}`;
|
|
||||||
|
const link = `${appUrl}/share/${share.id}`;
|
||||||
return (
|
return (
|
||||||
<Stack align="stretch">
|
<Stack align="stretch">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
readOnly
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={link}
|
value={link}
|
||||||
rightSection={
|
rightSection={
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const showCreateUploadModal = (
|
|||||||
modals: ModalsContextProps,
|
modals: ModalsContextProps,
|
||||||
options: {
|
options: {
|
||||||
isUserSignedIn: boolean;
|
isUserSignedIn: boolean;
|
||||||
|
appUrl: string;
|
||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
},
|
},
|
||||||
@@ -53,6 +54,7 @@ const CreateUploadModalBody = ({
|
|||||||
uploadCallback: (createShare: CreateShare) => void;
|
uploadCallback: (createShare: CreateShare) => void;
|
||||||
options: {
|
options: {
|
||||||
isUserSignedIn: boolean;
|
isUserSignedIn: boolean;
|
||||||
|
appUrl: string;
|
||||||
allowUnauthenticatedShares: boolean;
|
allowUnauthenticatedShares: boolean;
|
||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
};
|
};
|
||||||
@@ -156,7 +158,7 @@ const CreateUploadModalBody = ({
|
|||||||
color: theme.colors.gray[6],
|
color: theme.colors.gray[6],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{window.location.origin}/share/
|
{options.appUrl}/share/
|
||||||
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
|
||||||
</Text>
|
</Text>
|
||||||
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
<Grid align={form.errors.link ? "center" : "flex-end"}>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
const systemTheme = useColorScheme();
|
const systemTheme = useColorScheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const preferences = usePreferences();
|
const preferences = usePreferences();
|
||||||
|
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
|
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
@@ -89,7 +88,7 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
<Container>
|
<Container>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</Container>
|
</Container>
|
||||||
</UserContext.Provider>{" "}
|
</UserContext.Provider>
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
)}
|
)}
|
||||||
</GlobalLoadingContext.Provider>
|
</GlobalLoadingContext.Provider>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { TbLink, TbTrash } from "react-icons/tb";
|
import { TbLink, TbTrash } from "react-icons/tb";
|
||||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../components/Meta";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
import { MyShare } from "../../types/share.type";
|
import { MyShare } from "../../types/share.type";
|
||||||
@@ -28,6 +29,8 @@ const MyShares = () => {
|
|||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const [shares, setShares] = useState<MyShare[]>();
|
const [shares, setShares] = useState<MyShare[]>();
|
||||||
@@ -86,13 +89,17 @@ const MyShares = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.isSecureContext) {
|
if (window.isSecureContext) {
|
||||||
clipboard.copy(
|
clipboard.copy(
|
||||||
`${window.location.origin}/share/${share.id}`
|
`${config.get("APP_URL")}/share/${share.id}`
|
||||||
);
|
);
|
||||||
toast.success(
|
toast.success(
|
||||||
"Your link was copied to the keyboard."
|
"Your link was copied to the keyboard."
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showShareLinkModal(modals, share.id);
|
showShareLinkModal(
|
||||||
|
modals,
|
||||||
|
share.id,
|
||||||
|
config.get("APP_URL")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Col, createStyles, Grid, Paper, Text } from "@mantine/core";
|
import {
|
||||||
|
Center,
|
||||||
|
Col,
|
||||||
|
createStyles,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TbSettings, TbUsers } from "react-icons/tb";
|
import { useEffect, useState } from "react";
|
||||||
|
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
|
||||||
const managementOptions = [
|
import configService from "../../services/config.service";
|
||||||
{
|
|
||||||
title: "User management",
|
|
||||||
icon: TbUsers,
|
|
||||||
route: "/admin/users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Configuration",
|
|
||||||
icon: TbSettings,
|
|
||||||
route: "/admin/config",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
item: {
|
item: {
|
||||||
@@ -33,27 +31,69 @@ const useStyles = createStyles((theme) => ({
|
|||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
const { classes, theme } = useStyles();
|
const { classes, theme } = useStyles();
|
||||||
|
|
||||||
|
const [managementOptions, setManagementOptions] = useState([
|
||||||
|
{
|
||||||
|
title: "User management",
|
||||||
|
icon: TbUsers,
|
||||||
|
route: "/admin/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configuration",
|
||||||
|
icon: TbSettings,
|
||||||
|
route: "/admin/config",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configService.isNewReleaseAvailable().then((isNewReleaseAvailable) => {
|
||||||
|
if (isNewReleaseAvailable) {
|
||||||
|
setManagementOptions([
|
||||||
|
...managementOptions,
|
||||||
|
{
|
||||||
|
title: "Update",
|
||||||
|
icon: TbRefresh,
|
||||||
|
route:
|
||||||
|
"https://github.com/stonith404/pingvin-share/releases/latest",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p={40}>
|
<>
|
||||||
<Grid mt="md">
|
<Title mb={30} order={3}>
|
||||||
{managementOptions.map((item) => {
|
Administration
|
||||||
return (
|
</Title>
|
||||||
<Col xs={6} key={item.route}>
|
<Stack justify="space-between" style={{ height: "calc(100vh - 180px)" }}>
|
||||||
<Paper
|
<Paper withBorder p={40}>
|
||||||
withBorder
|
<Grid>
|
||||||
component={Link}
|
{managementOptions.map((item) => {
|
||||||
href={item.route}
|
return (
|
||||||
key={item.title}
|
<Col xs={6} key={item.route}>
|
||||||
className={classes.item}
|
<Paper
|
||||||
>
|
withBorder
|
||||||
<item.icon color={theme.colors.victoria[8]} size={35} />
|
component={Link}
|
||||||
<Text mt={7}>{item.title}</Text>
|
href={item.route}
|
||||||
</Paper>
|
key={item.title}
|
||||||
</Col>
|
className={classes.item}
|
||||||
);
|
>
|
||||||
})}
|
<item.icon color={theme.colors.victoria[8]} size={35} />
|
||||||
</Grid>
|
<Text mt={7}>{item.title}</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Version {process.env.VERSION}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,21 +47,19 @@ const Share = ({ shareId }: { shareId: string }) => {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const { error } = e.response.data;
|
const { error } = e.response.data;
|
||||||
if (e.response.status == 404) {
|
if (e.response.status == 404) {
|
||||||
showErrorModal(
|
if (error == "share_removed") {
|
||||||
modals,
|
showErrorModal(modals, "Share removed", e.response.data.message);
|
||||||
"Not found",
|
} else {
|
||||||
"This share can't be found. Please check your link."
|
showErrorModal(
|
||||||
);
|
modals,
|
||||||
|
"Not found",
|
||||||
|
"This share can't be found. Please check your link."
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (error == "share_password_required") {
|
} else if (error == "share_password_required") {
|
||||||
showEnterPasswordModal(modals, getShareToken);
|
showEnterPasswordModal(modals, getShareToken);
|
||||||
} else if (error == "share_token_required") {
|
} else if (error == "share_token_required") {
|
||||||
getShareToken();
|
getShareToken();
|
||||||
} else if (error == "forbidden") {
|
|
||||||
showErrorModal(
|
|
||||||
modals,
|
|
||||||
"Forbidden",
|
|
||||||
"You're not allowed to see this share. Are you logged in with the correct account?"
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showErrorModal(modals, "Error", "An unknown error occurred.");
|
showErrorModal(modals, "Error", "An unknown error occurred.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, Group } from "@mantine/core";
|
import { Button, Group } from "@mantine/core";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import axios from "axios";
|
import { cleanNotifications } from "@mantine/notifications";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -16,8 +17,10 @@ import { FileUpload } from "../types/File.type";
|
|||||||
import { CreateShare, Share } from "../types/share.type";
|
import { CreateShare, Share } from "../types/share.type";
|
||||||
import toast from "../utils/toast.util";
|
import toast from "../utils/toast.util";
|
||||||
|
|
||||||
let createdShare: Share;
|
|
||||||
const promiseLimit = pLimit(3);
|
const promiseLimit = pLimit(3);
|
||||||
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
let errorToastShown = false;
|
||||||
|
let createdShare: Share;
|
||||||
|
|
||||||
const Upload = () => {
|
const Upload = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,74 +33,122 @@ const Upload = () => {
|
|||||||
|
|
||||||
const uploadFiles = async (share: CreateShare) => {
|
const uploadFiles = async (share: CreateShare) => {
|
||||||
setisUploading(true);
|
setisUploading(true);
|
||||||
try {
|
createdShare = await shareService.create(share);
|
||||||
setFiles((files) =>
|
|
||||||
files.map((file) => {
|
|
||||||
file.uploadingProgress = 1;
|
|
||||||
return file;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
createdShare = await shareService.create(share);
|
|
||||||
|
|
||||||
const uploadPromises = files.map((file, i) => {
|
const fileUploadPromises = files.map(async (file, fileIndex) =>
|
||||||
// Callback to indicate current upload progress
|
// Limit the number of concurrent uploads to 3
|
||||||
const progressCallBack = (progress: number) => {
|
promiseLimit(async () => {
|
||||||
setFiles((files) => {
|
let fileId: string;
|
||||||
return files.map((file, callbackIndex) => {
|
|
||||||
if (i == callbackIndex) {
|
const setFileProgress = (progress: number) => {
|
||||||
|
setFiles((files) =>
|
||||||
|
files.map((file, callbackIndex) => {
|
||||||
|
if (fileIndex == callbackIndex) {
|
||||||
file.uploadingProgress = progress;
|
file.uploadingProgress = progress;
|
||||||
}
|
}
|
||||||
return file;
|
return file;
|
||||||
});
|
})
|
||||||
});
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
setFileProgress(1);
|
||||||
return promiseLimit(() =>
|
|
||||||
shareService.uploadFile(share.id, file, progressCallBack)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
file.uploadingProgress = -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(uploadPromises);
|
const chunks = Math.ceil(file.size / chunkSize);
|
||||||
} catch (e) {
|
|
||||||
if (axios.isAxiosError(e)) {
|
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
|
||||||
toast.error(e.response?.data?.message ?? "An unkown error occured.");
|
const from = chunkIndex * chunkSize;
|
||||||
} else {
|
const to = from + chunkSize;
|
||||||
toast.error("An unkown error occured.");
|
const blob = file.slice(from, to);
|
||||||
}
|
try {
|
||||||
setisUploading(false);
|
await new Promise((resolve, reject) => {
|
||||||
}
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) =>
|
||||||
|
await shareService
|
||||||
|
.uploadFile(
|
||||||
|
createdShare.id,
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
},
|
||||||
|
chunkIndex,
|
||||||
|
Math.ceil(file.size / chunkSize)
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.all(fileUploadPromises);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Check if there are any files that failed to upload
|
||||||
|
const fileErrorCount = files.filter(
|
||||||
|
(file) => file.uploadingProgress == -1
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (fileErrorCount > 0) {
|
||||||
|
if (!errorToastShown) {
|
||||||
|
toast.error(
|
||||||
|
`${fileErrorCount} file(s) failed to upload. Trying again.`,
|
||||||
|
{
|
||||||
|
disallowClose: true,
|
||||||
|
autoClose: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
errorToastShown = true;
|
||||||
|
} else {
|
||||||
|
cleanNotifications();
|
||||||
|
errorToastShown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete share
|
||||||
if (
|
if (
|
||||||
files.length > 0 &&
|
files.length > 0 &&
|
||||||
files.every(
|
files.every((file) => file.uploadingProgress >= 100) &&
|
||||||
(file) => file.uploadingProgress >= 100 || file.uploadingProgress == -1
|
fileErrorCount == 0
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
const fileErrorCount = files.filter(
|
shareService
|
||||||
(file) => file.uploadingProgress == -1
|
.completeShare(createdShare.id)
|
||||||
).length;
|
.then(() => {
|
||||||
setisUploading(false);
|
setisUploading(false);
|
||||||
if (fileErrorCount > 0) {
|
showCompletedUploadModal(modals, createdShare, config.get("APP_URL"));
|
||||||
toast.error(`${fileErrorCount} file(s) failed to upload. Try again.`);
|
setFiles([]);
|
||||||
} else {
|
})
|
||||||
shareService
|
.catch(() =>
|
||||||
.completeShare(createdShare.id)
|
toast.error("An error occurred while finishing your share.")
|
||||||
.then(() => {
|
);
|
||||||
showCompletedUploadModal(modals, createdShare);
|
|
||||||
setFiles([]);
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
toast.error("An error occured while finishing your share.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +164,7 @@ const Upload = () => {
|
|||||||
modals,
|
modals,
|
||||||
{
|
{
|
||||||
isUserSignedIn: user ? true : false,
|
isUserSignedIn: user ? true : false,
|
||||||
|
appUrl: config.get("APP_URL"),
|
||||||
allowUnauthenticatedShares: config.get(
|
allowUnauthenticatedShares: config.get(
|
||||||
"ALLOW_UNAUTHENTICATED_SHARES"
|
"ALLOW_UNAUTHENTICATED_SHARES"
|
||||||
),
|
),
|
||||||
@@ -125,7 +177,7 @@ const Upload = () => {
|
|||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Dropzone setFiles={setFiles} isUploading={isUploading} />
|
<Dropzone files={files} setFiles={setFiles} isUploading={isUploading} />
|
||||||
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import axios, { AxiosError } from "axios";
|
import axios from "axios";
|
||||||
import { getCookie } from "cookies-next";
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const accessToken = getCookie("access_token");
|
|
||||||
if (accessToken) {
|
|
||||||
config!.headers!.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error: AxiosError) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCookie, setCookie } from "cookies-next";
|
import { getCookie } from "cookies-next";
|
||||||
import * as jose from "jose";
|
import * as jose from "jose";
|
||||||
import api from "./api.service";
|
import api from "./api.service";
|
||||||
|
|
||||||
@@ -12,11 +12,6 @@ const signIn = async (emailOrUsername: string, password: string) => {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken, {
|
|
||||||
maxAge: 60 * 60 * 24 * 30 * 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,45 +32,30 @@ const signInTotp = async (
|
|||||||
loginToken,
|
loginToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken, {
|
|
||||||
maxAge: 60 * 60 * 24 * 30 * 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signUp = async (email: string, username: string, password: string) => {
|
const signUp = async (email: string, username: string, password: string) => {
|
||||||
const response = await api.post("auth/signUp", { email, username, password });
|
const response = await api.post("auth/signUp", { email, username, password });
|
||||||
|
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken, {
|
|
||||||
maxAge: 60 * 60 * 24 * 30 * 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signOut = () => {
|
const signOut = async () => {
|
||||||
setCookie("access_token", null);
|
await api.post("/auth/signOut");
|
||||||
setCookie("refresh_token", null);
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAccessToken = async () => {
|
const refreshAccessToken = async () => {
|
||||||
try {
|
try {
|
||||||
const accessToken = getCookie("access_token") as string;
|
const accessToken = getCookie("access_token") as string;
|
||||||
const refreshToken = getCookie("refresh_token");
|
|
||||||
if (
|
if (
|
||||||
(accessToken &&
|
!accessToken ||
|
||||||
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 <
|
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 < Date.now() + 2 * 60 * 1000
|
||||||
Date.now() + 2 * 60 * 1000) ||
|
|
||||||
(refreshToken && !accessToken)
|
|
||||||
) {
|
) {
|
||||||
const response = await api.post("auth/token", { refreshToken });
|
await api.post("/auth/token");
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
console.info("Refresh token invalid or expired");
|
console.info("Refresh token invalid or expired");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from "axios";
|
||||||
import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
|
import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
|
||||||
import api from "./api.service";
|
import api from "./api.service";
|
||||||
|
|
||||||
@@ -36,6 +37,15 @@ const sendTestEmail = async (email: string) => {
|
|||||||
await api.post("/configs/admin/testEmail", { email });
|
await api.post("/configs/admin/testEmail", { email });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNewReleaseAvailable = async () => {
|
||||||
|
const response = (
|
||||||
|
await axios.get(
|
||||||
|
"https://api.github.com/repos/stonith404/pingvin-share/releases/latest"
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
return response.tag_name.replace("v", "") != process.env.VERSION;
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
list,
|
list,
|
||||||
listForAdmin,
|
listForAdmin,
|
||||||
@@ -43,4 +53,5 @@ export default {
|
|||||||
get,
|
get,
|
||||||
finishSetup,
|
finishSetup,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
|
isNewReleaseAvailable,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FileUploadResponse } from "../types/File.type";
|
||||||
import {
|
import {
|
||||||
CreateShare,
|
CreateShare,
|
||||||
MyShare,
|
MyShare,
|
||||||
@@ -74,22 +75,27 @@ const downloadFile = async (shareId: string, fileId: string) => {
|
|||||||
|
|
||||||
const uploadFile = async (
|
const uploadFile = async (
|
||||||
shareId: string,
|
shareId: string,
|
||||||
file: File,
|
readerEvent: ProgressEvent<FileReader>,
|
||||||
progressCallBack: (uploadingProgress: number) => void
|
file: {
|
||||||
) => {
|
id?: string;
|
||||||
let formData = new FormData();
|
name: string;
|
||||||
formData.append("file", file);
|
},
|
||||||
|
chunkIndex: number,
|
||||||
|
totalChunks: number
|
||||||
|
): Promise<FileUploadResponse> => {
|
||||||
|
const data = readerEvent.target!.result;
|
||||||
|
|
||||||
const response = await api.post(`shares/${shareId}/files`, formData, {
|
return (
|
||||||
onUploadProgress: (progressEvent) => {
|
await api.post(`shares/${shareId}/files`, data, {
|
||||||
const uploadingProgress = Math.round(
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
(100 * progressEvent.loaded) / (progressEvent.total ?? 1)
|
params: {
|
||||||
);
|
id: file.id,
|
||||||
if (uploadingProgress < 100) progressCallBack(uploadingProgress);
|
name: file.name,
|
||||||
},
|
chunkIndex,
|
||||||
});
|
totalChunks,
|
||||||
progressCallBack(100);
|
},
|
||||||
return response;
|
})
|
||||||
|
).data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export type FileUpload = File & { uploadingProgress: number };
|
export type FileUpload = File & { uploadingProgress: number };
|
||||||
|
|
||||||
|
export type FileUploadResponse = { id: string; name: string };
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import { showNotification } from "@mantine/notifications";
|
import { NotificationProps, showNotification } from "@mantine/notifications";
|
||||||
import { TbCheck, TbX } from "react-icons/tb";
|
import { TbCheck, TbX } from "react-icons/tb";
|
||||||
const error = (message: string) =>
|
const error = (message: string, config?: Omit<NotificationProps, "message">) =>
|
||||||
showNotification({
|
showNotification({
|
||||||
icon: <TbX />,
|
icon: <TbX />,
|
||||||
color: "red",
|
color: "red",
|
||||||
radius: "md",
|
radius: "md",
|
||||||
title: "Error",
|
title: "Error",
|
||||||
|
|
||||||
message: message,
|
message: message,
|
||||||
|
|
||||||
|
autoClose: true,
|
||||||
|
|
||||||
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
const axiosError = (axiosError: any) =>
|
const axiosError = (axiosError: any) =>
|
||||||
error(axiosError?.response?.data?.message ?? "An unknown error occured");
|
error(axiosError?.response?.data?.message ?? "An unknown error occurred");
|
||||||
|
|
||||||
const success = (message: string) =>
|
const success = (
|
||||||
|
message: string,
|
||||||
|
config?: Omit<NotificationProps, "message">
|
||||||
|
) =>
|
||||||
showNotification({
|
showNotification({
|
||||||
icon: <TbCheck />,
|
icon: <TbCheck />,
|
||||||
color: "green",
|
color: "green",
|
||||||
radius: "md",
|
radius: "md",
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: message,
|
message: message,
|
||||||
|
autoClose: true,
|
||||||
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
||||||
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
||||||
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
|
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
|
||||||
"release:patch": "npm version patch -m 'release: %s' && git push && git push --tags",
|
"release:patch": "cd backend && npm version patch --commit-hooks false && cd ../frontend && npm version patch --commit-hooks false && cd .. && git add . && npm version patch --force -m 'release: %s' && git push && git push --tags",
|
||||||
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags",
|
"release:minor": "cd backend && npm version minor --commit-hooks false && cd ../frontend && npm version minor --commit-hooks false && cd .. && git add . && npm version minor --force -m 'release: %s' && git push && git push --tags",
|
||||||
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
|
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user