Compare commits

..

21 Commits

Author SHA1 Message Date
Elias Schneider
e09213a295 release: 0.7.0 2023-01-13 10:59:52 +01:00
Elias Schneider
fc116d65c0 chore: dump packages 2023-01-13 10:31:22 +01:00
Elias Schneider
76088cc76a feat: add ClamAV to scan for malicious files 2023-01-13 10:16:35 +01:00
Elias Schneider
16b697053a ci/cd: don't stale feature issues 2023-01-12 13:47:09 +01:00
Elias Schneider
349bf475cc fix: invalid github release link on admin page 2023-01-11 22:32:37 +01:00
Elias Schneider
fccc4cbc02 release: 0.6.1 2023-01-11 13:08:09 +01:00
Elias Schneider
f1b44f87fa fix: shareUrl uses wrong origin 2023-01-11 13:06:38 +01:00
Elias Schneider
02e41e2437 feat: delete all sessions if password was changed 2023-01-10 13:32:37 +01:00
Elias Schneider
74e8956106 fix: update password doesn't work 2023-01-10 12:29:38 +01:00
Elias Schneider
dc9ec429c6 release: 0.6.0 2023-01-09 12:14:41 +01:00
Elias Schneider
653d72bcb9 feat: chunk uploads (#76)
* add first concept

* finished first concept

* allow 3 uploads at same time

* retry if chunk failed

* updated clean temporary files job

* fix throttling for chunk uploads

* update tests

* remove multer

* migrate from `MAX_FILE_SIZE` to `MAX_SHARE_SIZE`

* improve error handling if file failed to upload

* fix promise limit

* improve file progress
2023-01-09 11:43:48 +01:00
Elias Schneider
a5bef5d4a4 fix: refresh token expires after 1 day instead of 3 months 2023-01-07 12:16:03 +01:00
Elias Schneider
c8ad2225e3 fix: access token refreshes even it is still valid 2023-01-06 16:07:07 +01:00
Elias Schneider
72c8081e7c fix: error message typo 2023-01-06 09:21:46 +01:00
Elias Schneider
f2d4895e50 fix: migration for v0.5.1 2023-01-05 08:34:31 +01:00
Elias Schneider
54f591cd60 release: 0.5.1 2023-01-04 16:02:54 +01:00
Elias Schneider
f836a0a3cd chore: add db migration 2023-01-04 15:58:15 +01:00
Elias Schneider
11174656e4 fix: email configuration updated without restart 2023-01-04 15:30:49 +01:00
Elias Schneider
faea1abcc4 feat: use cookies for authentication 2023-01-04 11:54:28 +01:00
Elias Schneider
71658ad39d feat: show version and show button if new release is available on admin page 2022-12-30 19:23:17 +01:00
Elias Schneider
167f0f8c7a chore: improve release scripts 2022-12-30 18:59:05 +01:00
55 changed files with 2054 additions and 1544 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Share" ADD COLUMN "removedReason" TEXT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()
refreshToken: string;
}

View 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,
});
}
}

View File

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

View 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 {}

View 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)`
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 * * * *")

View File

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

View File

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

View File

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

View File

@@ -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\"))"
] ]
} }
}, },

View File

@@ -1 +0,0 @@
This is a test filed used for uploading in the system test.

7
docker-compose-dev.yml Normal file
View File

@@ -0,0 +1,7 @@
version: '3.8'
services:
clamav:
restart: unless-stopped
ports:
- 3310:3310
image: clamav/clamav

View File

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

View File

@@ -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,
},
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;n&apos;drop files here to start your share. We can accept Drag&apos;n&apos;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>

View File

@@ -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} />;
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
</>
); );
}; };

View File

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

View File

@@ -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} />}
</> </>
); );

View File

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

View File

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

View File

@@ -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,
}; };

View File

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

View File

@@ -1 +1,3 @@
export type FileUpload = File & { uploadingProgress: number }; export type FileUpload = File & { uploadingProgress: number };
export type FileUploadResponse = { id: string; name: string };

View File

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

View File

@@ -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 ."
} }
} }