Compare commits

..

23 Commits

Author SHA1 Message Date
Elias Schneider
2158df4228 release: 0.13.0 2023-03-14 16:09:20 +01:00
Elias Schneider
37e765ddc7 fix: show line breaks in txt preview 2023-03-14 16:08:57 +01:00
Elias Schneider
a91c531642 docs: update main screenshot 2023-03-14 15:47:42 +01:00
Elias Schneider
5a7f7ca2f6 chore: dump node js version 2023-03-14 15:36:35 +01:00
Elias Schneider
813ee4de2c refactor: rename deprecated Prisma imports 2023-03-14 15:11:24 +01:00
Elias Schneider
b25c30d1ed feat: sort shared files 2023-03-14 14:50:18 +01:00
Elias Schneider
c807d208d8 feat: add preview modal 2023-03-14 12:09:21 +01:00
Elias Schneider
f82099f36e fix: upload file if it is 0 bytes 2023-03-13 08:57:56 +01:00
Elias Schneider
6345e21db9 refactor: globalize modal title style 2023-03-13 08:50:54 +01:00
Elias Schneider
f55aa80516 fix: replace "pingvin share" with dynamic app name 2023-03-12 20:13:55 +01:00
Elias Schneider
0ce8b528e1 refactor: improve error handling for failed emails 2023-03-12 19:29:39 +01:00
Elias Schneider
8ff417a013 fix: set password manually input not shown 2023-03-12 19:28:50 +01:00
Elias Schneider
cb1a0d4090 release: 0.12.1 2023-03-11 12:40:27 +01:00
Elias Schneider
753dbe83b7 fix: 48px icon does not update 2023-03-11 12:33:22 +01:00
Elias Schneider
0c2a62b0ca release: 0.12.0 2023-03-10 09:40:19 +01:00
Elias Schneider
452c635933 chore: dump packages 2023-03-10 09:40:09 +01:00
Elias Schneider
0455ba1bc1 chore: upgrade mantine to v6 2023-03-10 09:01:33 +01:00
Elias Schneider
3ad6b03b6b fix: home page shown even if disabled 2023-03-10 08:40:32 +01:00
Elias Schneider
91c3525b15 chore: add sharp for image optimizations 2023-03-08 17:47:36 +01:00
Elias Schneider
8403d7e14d feat: ability to change logo in frontend 2023-03-08 14:47:41 +01:00
Elias Schneider
8f71fd3435 fix: crypto is not defined 2023-03-08 13:10:10 +01:00
Elias Schneider
155c743197 release: 0.11.1 2023-03-05 10:50:32 +01:00
Elias Schneider
8b77e81d4c fix: old config variable prevents to create a share 2023-03-05 10:48:01 +01:00
50 changed files with 3715 additions and 1397 deletions

View File

@@ -1,3 +1,46 @@
## [0.13.0](https://github.com/stonith404/pingvin-share/compare/v0.12.1...v0.13.0) (2023-03-14)
### Features
* add preview modal ([c807d20](https://github.com/stonith404/pingvin-share/commit/c807d208d8f0518f6390f9f0f3d0eb00c12d213b))
* sort shared files ([b25c30d](https://github.com/stonith404/pingvin-share/commit/b25c30d1ed57230096b17afaf8545c7b0ef2e4b1))
### Bug Fixes
* replace "pingvin share" with dynamic app name ([f55aa80](https://github.com/stonith404/pingvin-share/commit/f55aa805167f31864cb07e269a47533927cb533c))
* set password manually input not shown ([8ff417a](https://github.com/stonith404/pingvin-share/commit/8ff417a013a45a777308f71c4f0d1817bfeed6be))
* show line breaks in txt preview ([37e765d](https://github.com/stonith404/pingvin-share/commit/37e765ddc7b19554bc6fb50eb969984b58bf3cc5))
* upload file if it is 0 bytes ([f82099f](https://github.com/stonith404/pingvin-share/commit/f82099f36eb4699385fc16dfb0e0c02e5d55b1e3))
### [0.12.1](https://github.com/stonith404/pingvin-share/compare/v0.12.0...v0.12.1) (2023-03-11)
### Bug Fixes
* 48px icon does not update ([753dbe8](https://github.com/stonith404/pingvin-share/commit/753dbe83b770814115a2576c7a50e1bac9dc8ce1))
## [0.12.0](https://github.com/stonith404/pingvin-share/compare/v0.11.1...v0.12.0) (2023-03-10)
### Features
* ability to change logo in frontend ([8403d7e](https://github.com/stonith404/pingvin-share/commit/8403d7e14ded801c3842a9b3fd87c3f6824c519e))
### Bug Fixes
* crypto is not defined ([8f71fd3](https://github.com/stonith404/pingvin-share/commit/8f71fd343506506532c1a24a4c66a16b1021705f))
* home page shown even if disabled ([3ad6b03](https://github.com/stonith404/pingvin-share/commit/3ad6b03b6bd80168870049582683077b689fa548))
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
### Bug Fixes
* old config variable prevents to create a share ([8b77e81](https://github.com/stonith404/pingvin-share/commit/8b77e81d4c1b8a2bf798595f5a66079c40734e09))
## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04) ## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04)

View File

@@ -1,26 +1,26 @@
# Using node slim because prisma ORM needs libc for ARM builds # Using node slim because prisma ORM needs libc for ARM builds
# Stage 1: on frontend dependency change # Stage 1: on frontend dependency change
FROM node:18-slim AS frontend-dependencies FROM node:19-slim AS frontend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 2: on frontend change # Stage 2: on frontend change
FROM node:18-slim AS frontend-builder FROM node:19-slim AS frontend-builder
WORKDIR /opt/app WORKDIR /opt/app
COPY ./frontend . COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build RUN npm run build
# Stage 3: on backend dependency change # Stage 3: on backend dependency change
FROM node:18-slim AS backend-dependencies FROM node:19-slim AS backend-dependencies
WORKDIR /opt/app WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./ COPY backend/package.json backend/package-lock.json ./
RUN npm ci RUN npm ci
# Stage 4:on backend change # Stage 4:on backend change
FROM node:18-slim AS backend-builder FROM node:19-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app WORKDIR /opt/app
COPY ./backend . COPY ./backend .
@@ -29,7 +29,7 @@ RUN npx prisma generate
RUN npm run build && npm prune --production RUN npm run build && npm prune --production
# Stage 5: Final image # Stage 5: Final image
FROM node:18-slim AS runner FROM node:19-slim AS runner
ENV NODE_ENV=docker ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y openssl RUN apt-get update && apt-get install -y openssl

View File

@@ -16,7 +16,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
- [Demo](https://pingvin-share.dev.eliasschneider.com) - [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA) - [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/> <img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ Setup ## ⌨️ Setup
@@ -33,7 +33,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
Required tools: Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 14 - [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background - [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
@@ -96,18 +96,7 @@ docker compose up -d
### Custom branding ### Custom branding
#### Name You can change the name and the logo of the app by visiting the admin configuration page.
You can change the name of the app by visiting the admin configuration page and changing the `App Name`.
#### Logo
You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images:
- `logo.png` - The logo in the header and home page
- `favicon.png` - The favicon
- `opengraph.png` - The image used for sharing on social media
- `icons/*` - The icons used for the PWA
## 🖤 Contribute ## 🖤 Contribute

2052
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.11.0", "version": "0.13.0",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch", "dev": "cross-env NODE_ENV=development nest start --watch",
@@ -13,65 +13,68 @@
"seed": "ts-node prisma/seed/config.seed.ts" "seed": "ts-node prisma/seed/config.seed.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.3.9",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.2.1", "@nestjs/core": "^9.3.9",
"@nestjs/jwt": "^10.0.1", "@nestjs/jwt": "^10.0.2",
"@nestjs/passport": "^9.0.0", "@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.2.1", "@nestjs/platform-express": "^9.3.9",
"@nestjs/schedule": "^2.1.0", "@nestjs/schedule": "^2.2.0",
"@nestjs/swagger": "^6.2.1", "@nestjs/swagger": "^6.2.1",
"@nestjs/throttler": "^3.1.0", "@nestjs/throttler": "^4.0.0",
"@prisma/client": "^4.8.1", "@prisma/client": "^4.11.0",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"body-parser": "^1.20.1", "body-parser": "^1.20.2",
"clamscan": "^2.1.2", "clamscan": "^2.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.14.0",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.1",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0", "qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^4.0.4", "rimraf": "^4.4.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.31.3",
"ts-node": "^10.9.1" "ts-node": "^10.9.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.8", "@nestjs/cli": "^9.2.0",
"@nestjs/schematics": "^9.0.4", "@nestjs/schematics": "^9.0.4",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.3.9",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/clamscan": "^2.0.4", "@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.15", "@types/express": "^4.17.17",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/node": "^18.11.18", "@types/multer": "^1.4.7",
"@types/node": "^18.15.0",
"@types/nodemailer": "^6.4.7", "@types/nodemailer": "^6.4.7",
"@types/passport-jwt": "^3.0.8", "@types/passport-jwt": "^3.0.8",
"@types/qrcode-svg": "^1.1.1", "@types/qrcode-svg": "^1.1.1",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.48.1", "@typescript-eslint/parser": "^5.54.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.31.0", "eslint": "^8.35.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2", "newman": "^5.3.2",
"prettier": "^2.8.2", "prettier": "^2.8.4",
"prisma": "^4.9.0", "prisma": "^4.11.0",
"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.2", "tsconfig-paths": "4.1.2",
"typescript": "^4.9.4", "typescript": "^4.9.5",
"wait-on": "^7.0.1" "wait-on": "^7.0.1"
} }
} }

View File

@@ -6,7 +6,7 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2"; import * as argon from "argon2";
import * as moment from "moment"; import * as moment from "moment";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";

View File

@@ -8,6 +8,7 @@ import { User } from "@prisma/client";
import * as argon from "argon2"; import * as argon from "argon2";
import { authenticator, totp } from "otplib"; import { authenticator, totp } from "otplib";
import * as qrcode from "qrcode-svg"; import * as qrcode from "qrcode-svg";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
@@ -16,7 +17,8 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
export class AuthTotpService { export class AuthTotpService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private authService: AuthService private authService: AuthService,
private config: ConfigService
) {} ) {}
async signInTotp(dto: AuthSignInTotpDTO) { async signInTotp(dto: AuthSignInTotpDTO) {
@@ -42,7 +44,7 @@ export class AuthTotpService {
throw new UnauthorizedException("Invalid login token"); throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date()) if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired"); throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code // Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({ const { totpSecret } = await this.prisma.user.findUnique({
@@ -95,7 +97,7 @@ export class AuthTotpService {
const otpURL = totp.keyuri( const otpURL = totp.keyuri(
user.username || user.email, user.username || user.email,
"pingvin-share", this.config.get("general.appName"),
secret secret
); );

View File

@@ -1,12 +1,17 @@
import { import {
Body, Body,
Controller, Controller,
FileTypeValidator,
Get, Get,
Param, Param,
ParseFilePipe,
Patch, Patch,
Post, Post,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { SkipThrottle } from "@nestjs/throttler"; import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
@@ -16,11 +21,13 @@ import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto"; import { ConfigDTO } from "./dto/config.dto";
import { TestEmailDTO } from "./dto/testEmail.dto"; import { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto"; import UpdateConfigDTO from "./dto/updateConfig.dto";
import { LogoService } from "./logo.service";
@Controller("configs") @Controller("configs")
export class ConfigController { export class ConfigController {
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private logoService: LogoService,
private emailService: EmailService private emailService: EmailService
) {} ) {}
@@ -51,4 +58,18 @@ export class ConfigController {
async testEmail(@Body() { email }: TestEmailDTO) { async testEmail(@Body() { email }: TestEmailDTO) {
await this.emailService.sendTestMail(email); await this.emailService.sendTestMail(email);
} }
@Post("admin/logo")
@UseInterceptors(FileInterceptor("file"))
@UseGuards(JwtGuard, AdministratorGuard)
async uploadLogo(
@UploadedFile(
new ParseFilePipe({
validators: [new FileTypeValidator({ fileType: "image/png" })],
})
)
file: Express.Multer.File
) {
return await this.logoService.create(file.buffer);
}
} }

View File

@@ -3,6 +3,7 @@ import { EmailModule } from "src/email/email.module";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller"; import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
import { LogoService } from "./logo.service";
@Global() @Global()
@Module({ @Module({
@@ -16,6 +17,7 @@ import { ConfigService } from "./config.service";
inject: [PrismaService], inject: [PrismaService],
}, },
ConfigService, ConfigService,
LogoService,
], ],
controllers: [ConfigController], controllers: [ConfigController],
exports: [ConfigService], exports: [ConfigService],

View File

@@ -0,0 +1,32 @@
import { Injectable } from "@nestjs/common";
import * as fs from "fs";
import * as sharp from "sharp";
const IMAGES_PATH = "../frontend/public/img";
@Injectable()
export class LogoService {
async create(file: Buffer) {
fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary");
this.createFavicon(file);
this.createPWAIcons(file);
}
async createFavicon(file: Buffer) {
const resized = await sharp(file).resize(16).toBuffer();
fs.promises.writeFile(`${IMAGES_PATH}/favicon.ico`, resized, "binary");
}
async createPWAIcons(file: Buffer) {
const sizes = [48, 72, 96, 128, 144, 152, 192, 384, 512];
for (const size of sizes) {
const resized = await sharp(file).resize(size).toBuffer();
fs.promises.writeFile(
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
resized,
"binary"
);
}
}
}

View File

@@ -1,4 +1,8 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common"; import {
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as nodemailer from "nodemailer"; import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
@@ -6,6 +10,7 @@ import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() { getTransporter() {
if (!this.config.get("smtp.enabled")) if (!this.config.get("smtp.enabled"))
@@ -22,6 +27,22 @@ export class EmailService {
}); });
} }
private async sendMail(email: string, subject: string, text: string) {
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: email,
subject,
text,
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException("Failed to send email");
});
}
async sendMailToShareRecepients( async sendMailToShareRecepients(
recipientEmail: string, recipientEmail: string,
shareId: string, shareId: string,
@@ -32,34 +53,28 @@ export class EmailService {
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.shareRecipientsSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.shareRecipientsSubject"),
text: this.config
.get("email.shareRecipientsMessage") .get("email.shareRecipientsMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone") .replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl)
}); );
} }
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) { async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.reverseShareSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.reverseShareSubject"),
text: this.config
.get("email.reverseShareMessage") .get("email.reverseShareMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl)
}); );
} }
async sendResetPasswordEmail(recipientEmail: string, token: string) { async sendResetPasswordEmail(recipientEmail: string, token: string) {
@@ -67,47 +82,42 @@ export class EmailService {
"general.appUrl" "general.appUrl"
)}/auth/resetPassword/${token}`; )}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.resetPasswordSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.resetPasswordSubject"),
text: this.config
.get("email.resetPasswordMessage") .get("email.resetPasswordMessage")
.replaceAll("{url}", resetPasswordUrl), .replaceAll("\\n", "\n")
}); .replaceAll("{url}", resetPasswordUrl)
);
} }
async sendInviteEmail(recipientEmail: string, password: string) { async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`; const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.getTransporter().sendMail({ await this.sendMail(
from: `"${this.config.get("general.appName")}" <${this.config.get( recipientEmail,
"smtp.email" this.config.get("email.inviteSubject"),
)}>`, this.config
to: recipientEmail,
subject: this.config.get("email.inviteSubject"),
text: this.config
.get("email.inviteMessage") .get("email.inviteMessage")
.replaceAll("{url}", loginUrl) .replaceAll("{url}", loginUrl)
.replaceAll("{password}", password), .replaceAll("{password}", password)
}); );
} }
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
try { await this.getTransporter()
await this.getTransporter().sendMail({ .sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get( from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email" "smtp.email"
)}>`, )}>`,
to: recipientEmail, to: recipientEmail,
subject: "Test email", subject: "Test email",
text: "This is a test email", text: "This is a test email",
}); })
} catch (e) { .catch((e) => {
console.error(e); this.logger.error(e);
throw new InternalServerErrorException(e.message); throw new InternalServerErrorException(e.message);
} });
} }
} }

View File

@@ -51,7 +51,7 @@ export class FileController {
const zip = this.fileService.getZip(shareId); const zip = this.fileService.getZip(shareId);
res.set({ res.set({
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`), "Content-Disposition": contentDisposition(`${shareId}.zip`),
}); });
return new StreamableFile(zip); return new StreamableFile(zip);

View File

@@ -278,7 +278,7 @@ export class ShareService {
share?.security?.password && share?.security?.password &&
!(await argon.verify(share.security.password, password)) !(await argon.verify(share.security.password, password))
) { ) {
throw new ForbiddenException("Wrong password"); throw new ForbiddenException("Wrong password", "wrong_password");
} }
if (share.security?.maxViews && share.security.maxViews <= share.views) { if (share.security?.maxViews && share.security.maxViews <= share.views) {

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2"; import * as argon from "argon2";
import * as crypto from "crypto";
import { EmailService } from "src/email/email.service"; import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
@@ -28,7 +29,7 @@ export class UserSevice {
if (!dto.password) { if (!dto.password) {
const randomPassword = crypto.randomUUID(); const randomPassword = crypto.randomUUID();
hash = await argon.hash(randomPassword); hash = await argon.hash(randomPassword);
this.emailService.sendInviteEmail(dto.email, randomPassword); await this.emailService.sendInviteEmail(dto.email, randomPassword);
} else { } else {
hash = await argon.hash(dto.password); hash = await argon.hash(dto.password);
} }

View File

@@ -4,7 +4,15 @@ const { version } = require('./package.json');
const withPWA = require("next-pwa")({ const withPWA = require("next-pwa")({
dest: "public", dest: "public",
disable: process.env.NODE_ENV == "development", disable: process.env.NODE_ENV === "development",
reloadOnOnline: false,
runtimeCaching: [
{
urlPattern: /^https?.*/,
handler: 'NetworkOnly',
},
],
reloadOnOnline: false,
}); });
module.exports = withPWA({ module.exports = withPWA({

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.11.0", "version": "0.13.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -9,43 +9,44 @@
"format": "prettier --write \"src/**/*.ts*\"" "format": "prettier --write \"src/**/*.ts*\""
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@mantine/core": "^5.10.0", "@mantine/core": "^6.0.1",
"@mantine/dropzone": "^5.10.0", "@mantine/dropzone": "^6.0.1",
"@mantine/form": "^5.10.0", "@mantine/form": "^6.0.1",
"@mantine/hooks": "^5.10.0", "@mantine/hooks": "^6.0.1",
"@mantine/modals": "^5.10.0", "@mantine/modals": "^6.0.1",
"@mantine/next": "^5.10.0", "@mantine/next": "^6.0.1",
"@mantine/notifications": "^5.10.0", "@mantine/notifications": "^6.0.1",
"axios": "^1.2.2", "axios": "^1.3.4",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.11.2", "jose": "^4.13.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "^13.1.2", "next": "^13.2.4",
"next-cookies": "^2.0.3", "next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5", "next-http-proxy-middleware": "^1.2.5",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"p-limit": "^4.0.0", "p-limit": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.7.1", "react-icons": "^4.8.0",
"yup": "^0.32.11" "sharp": "^0.31.3",
"yup": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/node": "18.11.18", "@types/node": "18.15.0",
"@types/react": "18.0.26", "@types/react": "18.0.28",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.11",
"axios": "^1.2.2", "axios": "^1.3.4",
"eslint": "8.31.0", "eslint": "8.35.0",
"eslint-config-next": "^13.1.2", "eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.7.0",
"prettier": "^2.8.2", "prettier": "^2.8.4",
"tar": "^6.1.13", "tar": "^6.1.13",
"typescript": "^4.9.4" "typescript": "^4.9.5"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -7,7 +7,6 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
@@ -27,7 +26,7 @@ const showEnableTotpModal = (
} }
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Enable TOTP</Title>, title: "Enable TOTP",
children: ( children: (
<CreateEnableTotpModal options={options} refreshUser={refreshUser} /> <CreateEnableTotpModal options={options} refreshUser={refreshUser} />
), ),

View File

@@ -0,0 +1,39 @@
import { Box, FileInput, Group, Stack, Text, Title } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { Dispatch, SetStateAction } from "react";
import { TbUpload } from "react-icons/tb";
const LogoConfigInput = ({
logo,
setLogo,
}: {
logo: File | null;
setLogo: Dispatch<SetStateAction<File | null>>;
}) => {
const isMobile = useMediaQuery("(max-width: 560px)");
return (
<Group position="apart">
<Stack style={{ maxWidth: isMobile ? "100%" : "40%" }} spacing={0}>
<Title order={6}>Logo</Title>
<Text color="dimmed" size="sm" mb="xs">
Change your logo by uploading a new image. The image must be a PNG and
should have the format 1:1.
</Text>
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
<FileInput
clearable
icon={<TbUpload size={14} />}
value={logo}
onChange={(v) => setLogo(v)}
accept=".png"
placeholder="Pick image"
/>
</Box>
</Group>
);
};
export default LogoConfigInput;

View File

@@ -5,7 +5,6 @@ import {
Stack, Stack,
Switch, Switch,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -19,7 +18,7 @@ const showCreateUserModal = (
getUsers: () => void getUsers: () => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Create user</Title>, title: "Create user",
children: ( children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} /> <Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
), ),
@@ -79,13 +78,12 @@ const Body = ({
})} })}
/> />
)} )}
{form.values.setPasswordManually || {(form.values.setPasswordManually || !smtpEnabled) && (
(!smtpEnabled && (
<PasswordInput <PasswordInput
label="Password" label="Password"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
))} )}
<Switch <Switch
styles={{ styles={{
body: { body: {

View File

@@ -6,7 +6,6 @@ import {
Stack, Stack,
Switch, Switch,
TextInput, TextInput,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -21,7 +20,7 @@ const showUpdateUserModal = (
getUsers: () => void getUsers: () => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Update {user.username}</Title>, title: `Update ${user.username}`,
children: <Body user={user} modals={modals} getUsers={getUsers} />, children: <Body user={user} modals={modals} getUsers={getUsers} />,
}); });
}; };

View File

@@ -32,11 +32,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(), emailOrUsername: yup.string().required(),
password: yup.string().min(8).required(), password: yup.string().min(8).required(),
totp: yup.string().when("totpRequired", {
is: true,
then: yup.string().min(6).max(6).required(),
otherwise: yup.string(),
}),
}); });
const form = useForm({ const form = useForm({
@@ -79,8 +74,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
router.replace(redirectPath); router.replace(redirectPath);
}) })
.catch((error) => { .catch((error) => {
if (error?.response?.data?.message == "Login token expired") { if (error?.response?.data?.error == "share_password_required") {
toast.error("Login token expired"); toast.axiosError(error);
// Refresh the page to start over // Refresh the page to start over
window.location.reload(); window.location.reload();
} }

View File

@@ -0,0 +1,41 @@
import { ActionIcon } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
import { TbChevronDown, TbChevronUp, TbSelector } from "react-icons/tb";
export type TableSort = {
property?: string;
direction: "asc" | "desc";
};
const TableSortIcon = ({
sort,
setSort,
property,
}: {
sort: TableSort;
setSort: Dispatch<SetStateAction<TableSort>>;
property: string;
}) => {
if (sort.property === property) {
return (
<ActionIcon
onClick={() =>
setSort({
property,
direction: sort.direction === "asc" ? "desc" : "asc",
})
}
>
{sort.direction === "asc" ? <TbChevronDown /> : <TbChevronUp />}
</ActionIcon>
);
} else {
return (
<ActionIcon onClick={() => setSort({ property, direction: "asc" })}>
<TbSelector />
</ActionIcon>
);
}
};
export default TableSortIcon;

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Box,
Group, Group,
Skeleton, Skeleton,
Stack, Stack,
@@ -8,9 +9,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import mime from "mime-types"; import { Dispatch, SetStateAction, useEffect, useState } from "react";
import Link from "next/link";
import { TbDownload, TbEye, TbLink } from "react-icons/tb"; import { TbDownload, TbEye, TbLink } from "react-icons/tb";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
@@ -18,13 +17,17 @@ import { FileMetaData } from "../../types/File.type";
import { Share } from "../../types/share.type"; import { Share } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util"; import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
import TableSortIcon, { TableSort } from "../core/SortIcon";
import showFilePreviewModal from "./modals/showFilePreviewModal";
const FileList = ({ const FileList = ({
files, files,
setShare,
share, share,
isLoading, isLoading,
}: { }: {
files?: FileMetaData[]; files?: FileMetaData[];
setShare: Dispatch<SetStateAction<Share | undefined>>;
share: Share; share: Share;
isLoading: boolean; isLoading: boolean;
}) => { }) => {
@@ -32,6 +35,32 @@ const FileList = ({
const config = useConfig(); const config = useConfig();
const modals = useModals(); const modals = useModals();
const [sort, setSort] = useState<TableSort>({
property: undefined,
direction: "desc",
});
const sortFiles = () => {
if (files && sort.property) {
const sortedFiles = files.sort((a: any, b: any) => {
if (sort.direction === "asc") {
return b[sort.property!].localeCompare(a[sort.property!], undefined, {
numeric: true,
});
} else {
return a[sort.property!].localeCompare(b[sort.property!], undefined, {
numeric: true,
});
}
});
setShare({
...share,
files: sortedFiles,
});
}
};
const copyFileLink = (file: FileMetaData) => { const copyFileLink = (file: FileMetaData) => {
const link = `${config.get("general.appUrl")}/api/shares/${ const link = `${config.get("general.appUrl")}/api/shares/${
share.id share.id
@@ -52,12 +81,25 @@ const FileList = ({
} }
}; };
useEffect(sortFiles, [sort]);
return ( return (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>
<th>Size</th> <Group spacing="xs">
Name
<TableSortIcon sort={sort} setSort={setSort} property="name" />
</Group>
</th>
<th>
<Group spacing="xs">
Size
<TableSortIcon sort={sort} setSort={setSort} property="size" />
</Group>
</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -72,18 +114,19 @@ const FileList = ({
<Group position="right"> <Group position="right">
{shareService.doesFileSupportPreview(file.name) && ( {shareService.doesFileSupportPreview(file.name) && (
<ActionIcon <ActionIcon
component={Link} onClick={() =>
href={`/share/${share.id}/preview/${ showFilePreviewModal(share.id, file, modals)
file.id }
}?type=${mime.contentType(file.name)}`}
target="_blank"
size={25} size={25}
> >
<TbEye /> <TbEye />
</ActionIcon> </ActionIcon>
)} )}
{!share.hasPassword && ( {!share.hasPassword && (
<ActionIcon size={25} onClick={() => copyFileLink(file)}> <ActionIcon
size={25}
onClick={() => copyFileLink(file)}
>
<TbLink /> <TbLink />
</ActionIcon> </ActionIcon>
)} )}
@@ -101,6 +144,7 @@ const FileList = ({
))} ))}
</tbody> </tbody>
</Table> </Table>
</Box>
); );
}; };

View File

@@ -0,0 +1,158 @@
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { modals } from "@mantine/modals";
import Link from "next/link";
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import api from "../../services/api.service";
const FilePreviewContext = React.createContext<{
shareId: string;
fileId: string;
mimeType: string;
setIsNotSupported: Dispatch<SetStateAction<boolean>>;
}>({
shareId: "",
fileId: "",
mimeType: "",
setIsNotSupported: () => {},
});
const FilePreview = ({
shareId,
fileId,
mimeType,
}: {
shareId: string;
fileId: string;
mimeType: string;
}) => {
const [isNotSupported, setIsNotSupported] = useState(false);
if (isNotSupported) return <UnSupportedFile />;
return (
<Stack>
<FilePreviewContext.Provider
value={{ shareId, fileId, mimeType, setIsNotSupported }}
>
<FileDecider />
</FilePreviewContext.Provider>
<Button
variant="subtle"
component={Link}
onClick={() => modals.closeAll()}
target="_blank"
href={`/api/shares/${shareId}/files/${fileId}?download=false`}
>
View original file
</Button>
</Stack>
);
};
const FileDecider = () => {
const { mimeType, setIsNotSupported } = React.useContext(FilePreviewContext);
if (mimeType == "application/pdf") {
return <PdfPreview />;
} else if (mimeType.startsWith("video/")) {
return <VideoPreview />;
} else if (mimeType.startsWith("image/")) {
return <ImagePreview />;
} else if (mimeType.startsWith("audio/")) {
return <AudioPreview />;
} else if (mimeType.startsWith("text/")) {
return <TextPreview />;
} else {
setIsNotSupported(true);
return null;
}
};
const AudioPreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<audio controls style={{ width: "100%" }}>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
onError={() => setIsNotSupported(true)}
/>
</audio>
</Stack>
</Center>
);
};
const VideoPreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
<video width="100%" controls>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
onError={() => setIsNotSupported(true)}
/>
</video>
);
};
const ImagePreview = () => {
const { shareId, fileId, setIsNotSupported } =
React.useContext(FilePreviewContext);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
alt={`${fileId}_preview`}
width="100%"
onError={() => setIsNotSupported(true)}
/>
);
};
const TextPreview = () => {
const { shareId, fileId } = React.useContext(FilePreviewContext);
const [text, setText] = useState<string | null>(null);
useEffect(() => {
api.get(`/shares/${shareId}/files/${fileId}?download=false`).then((res) => {
console.log(res.data);
setText(res.data);
});
}, [shareId, fileId]);
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<Text sx={{ whiteSpace: "pre-wrap" }} size="sm">
{text}
</Text>
</Stack>
</Center>
);
};
const PdfPreview = () => {
const { shareId, fileId } = React.useContext(FilePreviewContext);
if (typeof window !== "undefined") {
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
}
return null;
};
const UnSupportedFile = () => {
return (
<Center style={{ minHeight: 200 }}>
<Stack align="center" spacing={10}>
<Title order={3}>Preview not supported</Title>
<Text>
A preview for thise file type is unsupported. Please download the file
to view it.
</Text>
</Stack>
</Center>
);
};
export default FilePreview;

View File

@@ -34,8 +34,10 @@ const FileSizeInput = ({
label={label} label={label}
value={size} value={size}
onChange={(value) => { onChange={(value) => {
setSize(value!); if (value) {
onChange(unitAndSizeToByte(unit, value!)); setSize(value);
onChange(unitAndSizeToByte(unit, value));
}
}} }}
/> />
</Col> </Col>

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Button, Stack, TextInput, Title } from "@mantine/core"; import { ActionIcon, Button, Stack, TextInput } from "@mantine/core";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -14,11 +14,7 @@ const showCompletedReverseShareModal = (
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: "Reverse share link",
<Stack align="stretch" spacing={0}>
<Title order={4}>Reverse share link</Title>
</Stack>
),
children: <Body link={link} getReverseShares={getReverseShares} />, children: <Body link={link} getReverseShares={getReverseShares} />,
}); });
}; };

View File

@@ -8,7 +8,6 @@ import {
Stack, Stack,
Switch, Switch,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
@@ -25,7 +24,7 @@ const showCreateReverseShareModal = (
getReverseShares: () => void getReverseShares: () => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Create reverse share</Title>, title: "Create reverse share",
children: ( children: (
<Body <Body
showSendEmailNotificationOption={showSendEmailNotificationOption} showSendEmailNotificationOption={showSendEmailNotificationOption}

View File

@@ -0,0 +1,21 @@
import { ModalsContextProps } from "@mantine/modals/lib/context";
import mime from "mime-types";
import { FileMetaData } from "../../../types/File.type";
import FilePreview from "../FilePreview";
const showFilePreviewModal = (
shareId: string,
file: FileMetaData,
modals: ModalsContextProps
) => {
const mimeType = (mime.contentType(file.name) || "").split(";")[0];
return modals.openModal({
size: "xl",
title: file.name,
children: (
<FilePreview shareId={shareId} fileId={file.id} mimeType={mimeType} />
),
});
};
export default showFilePreviewModal;

View File

@@ -1,33 +1,40 @@
import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core"; import { Button, PasswordInput, Stack, Text } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react"; import { useState } from "react";
const showEnterPasswordModal = ( const showEnterPasswordModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
submitCallback: any submitCallback: (password: string) => Promise<void>
) => { ) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: "Password required",
<>
<Title order={4}>Password required</Title>
<Text size="sm">
This access this share please enter the password for the share.
</Text>
</>
),
children: <Body submitCallback={submitCallback} />, children: <Body submitCallback={submitCallback} />,
}); });
}; };
const Body = ({ submitCallback }: { submitCallback: any }) => { const Body = ({
submitCallback,
}: {
submitCallback: (password: string) => Promise<void>;
}) => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordWrong, setPasswordWrong] = useState(false); const [passwordWrong, setPasswordWrong] = useState(false);
return ( return (
<>
<Stack align="stretch"> <Stack align="stretch">
<Text size="sm">
This access this share please enter the password for the share.
</Text>
<form
onSubmit={(e) => {
e.preventDefault();
submitCallback(password);
}}
>
<Stack>
<PasswordInput <PasswordInput
variant="filled" variant="filled"
placeholder="Password" placeholder="Password"
@@ -36,22 +43,10 @@ const Body = ({ submitCallback }: { submitCallback: any }) => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
value={password} value={password}
/> />
<Button <Button type="submit">Submit</Button>
onClick={() => </Stack>
submitCallback(password) </form>
.then((res: any) => res)
.catch((e: any) => {
const error = e.response.data.message;
if (error == "Wrong password") {
setPasswordWrong(true);
}
})
}
>
Submit
</Button>
</Stack> </Stack>
</>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -12,7 +12,7 @@ const showErrorModal = (
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: <Title order={4}>{title}</Title>, title: title,
children: <Body text={text} />, children: <Body text={text} />,
}); });

View File

@@ -1,11 +1,4 @@
import { import { ActionIcon, Button, Stack, Text, TextInput } from "@mantine/core";
ActionIcon,
Button,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -24,11 +17,7 @@ const showCompletedUploadModal = (
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: ( title: "Share ready",
<Stack align="stretch" spacing={0}>
<Title order={4}>Share ready</Title>
</Stack>
),
children: <Body share={share} appUrl={appUrl} />, children: <Body share={share} appUrl={appUrl} />,
}); });
}; };

View File

@@ -37,7 +37,7 @@ const showCreateUploadModal = (
uploadCallback: (createShare: CreateShare) => void uploadCallback: (createShare: CreateShare) => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Share</Title>, title: "Share",
children: ( children: (
<CreateUploadModalBody <CreateUploadModalBody
options={options} options={options}

View File

@@ -1,14 +1,13 @@
import React from "react";
import { import {
createStyles,
Title,
Text,
Button, Button,
Container, Container,
createStyles,
Group, Group,
Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import Meta from "../components/Meta";
import Link from "next/link"; import Link from "next/link";
import Meta from "../components/Meta";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
root: { root: {
@@ -21,7 +20,7 @@ const useStyles = createStyles((theme) => ({
fontWeight: 900, fontWeight: 900,
fontSize: 220, fontSize: 220,
lineHeight: 1, lineHeight: 1,
marginBottom: theme.spacing.xl * 1.5, marginBottom: `calc(${theme.spacing.xl} * 100)`,
color: theme.colors.gray[2], color: theme.colors.gray[2],
[theme.fn.smallerThan("sm")]: { [theme.fn.smallerThan("sm")]: {
@@ -32,7 +31,7 @@ const useStyles = createStyles((theme) => ({
description: { description: {
maxWidth: 500, maxWidth: 500,
margin: "auto", margin: "auto",
marginBottom: theme.spacing.xl * 1.5, marginBottom: `calc(${theme.spacing.xl} * 100)`,
}, },
})); }));

View File

@@ -6,7 +6,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useColorScheme } from "@mantine/hooks"; import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import axios from "axios"; import axios from "axios";
import { getCookie, setCookie } from "cookies-next"; import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
@@ -76,7 +76,7 @@ function App({ Component, pageProps }: AppProps) {
toggleColorScheme={toggleColorScheme} toggleColorScheme={toggleColorScheme}
> >
<GlobalStyle /> <GlobalStyle />
<NotificationsProvider> <Notifications />
<ModalsProvider> <ModalsProvider>
<ConfigContext.Provider <ConfigContext.Provider
value={{ value={{
@@ -109,7 +109,6 @@ function App({ Component, pageProps }: AppProps) {
</UserContext.Provider> </UserContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</ModalsProvider> </ModalsProvider>
</NotificationsProvider>
</ColorSchemeProvider> </ColorSchemeProvider>
</MantineProvider> </MantineProvider>
); );

View File

@@ -12,14 +12,8 @@ export default class _Document extends Document {
<Head> <Head>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link <link rel="apple-touch-icon" href="/img/icons/icon-128x128.png" />
rel="apple-touch-icon"
href="/img/icons/icon-white-128x128.png"
/>
<meta property="og:image" content="/img/opengraph.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/img/opengraph.png" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<meta name="theme-color" content="#46509e" /> <meta name="theme-color" content="#46509e" />
</Head> </Head>

View File

@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput"; import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput";
import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader"; import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader";
import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar"; import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar";
import LogoConfigInput from "../../../components/admin/configuration/LogoConfigInput";
import TestEmailButton from "../../../components/admin/configuration/TestEmailButton"; import TestEmailButton from "../../../components/admin/configuration/TestEmailButton";
import CenterLoader from "../../../components/core/CenterLoader"; import CenterLoader from "../../../components/core/CenterLoader";
import Meta from "../../../components/Meta"; import Meta from "../../../components/Meta";
@@ -36,14 +37,29 @@ export default function AppShellDemo() {
const isMobile = useMediaQuery("(max-width: 560px)"); const isMobile = useMediaQuery("(max-width: 560px)");
const config = useConfig(); const config = useConfig();
const categoryId = router.query.category as string; const categoryId = (router.query.category as string | undefined) ?? "general";
const [configVariables, setConfigVariables] = useState<AdminConfig[]>(); const [configVariables, setConfigVariables] = useState<AdminConfig[]>();
const [updatedConfigVariables, setUpdatedConfigVariables] = useState< const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[] UpdateConfig[]
>([]); >([]);
const [logo, setLogo] = useState<File | null>(null);
const saveConfigVariables = async () => { const saveConfigVariables = async () => {
if (logo) {
configService
.changeLogo(logo)
.then(() => {
setLogo(null);
toast.success(
"Logo updated successfully. It may take a few minutes to update on the website."
);
})
.catch(toast.axiosError);
}
if (updatedConfigVariables.length > 0) {
await configService await configService
.updateMany(updatedConfigVariables) .updateMany(updatedConfigVariables)
.then(() => { .then(() => {
@@ -52,6 +68,7 @@ export default function AppShellDemo() {
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
config.refresh(); config.refresh();
}
}; };
const updateConfigVariable = (configVariable: UpdateConfig) => { const updateConfigVariable = (configVariable: UpdateConfig) => {
@@ -129,6 +146,9 @@ export default function AppShellDemo() {
</Box> </Box>
</Group> </Group>
))} ))}
{categoryId == "general" && (
<LogoConfigInput logo={logo} setLogo={setLogo} />
)}
</Stack> </Stack>
<Group mt="lg" position="right"> <Group mt="lg" position="right">
{categoryId == "smtp" && ( {categoryId == "smtp" && (

View File

@@ -1,15 +0,0 @@
export function getServerSideProps() {
return {
redirect: {
permanent: false,
destination: "/admin/config/general",
},
props: {},
};
}
const Config = () => {
return null;
};
export default Config;

View File

@@ -41,7 +41,7 @@ const Admin = () => {
{ {
title: "Configuration", title: "Configuration",
icon: TbSettings, icon: TbSettings,
route: "/admin/config", route: "/admin/config/general",
}, },
]); ]);

View File

@@ -43,7 +43,7 @@ const Intro = () => {
<Text>Enough talked, have fun with Pingvin Share!</Text> <Text>Enough talked, have fun with Pingvin Share!</Text>
<Text mt="lg">How to you want to continue?</Text> <Text mt="lg">How to you want to continue?</Text>
<Stack> <Stack>
<Button href="/admin/config" component={Link}> <Button href="/admin/config/general" component={Link}>
Customize configuration Customize configuration
</Button> </Button>
<Button href="/" component={Link} variant="light"> <Button href="/" component={Link} variant="light">

View File

@@ -20,13 +20,13 @@ const useStyles = createStyles((theme) => ({
inner: { inner: {
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
paddingTop: theme.spacing.xl * 4, paddingTop: `calc(${theme.spacing.md} * 4)`,
paddingBottom: theme.spacing.xl * 4, paddingBottom: `calc(${theme.spacing.md} * 4)`,
}, },
content: { content: {
maxWidth: 480, maxWidth: 480,
marginRight: theme.spacing.xl * 3, marginRight: `calc(${theme.spacing.md} * 3)`,
[theme.fn.smallerThan("md")]: { [theme.fn.smallerThan("md")]: {
maxWidth: "100%", maxWidth: "100%",

View File

@@ -9,6 +9,7 @@ import showEnterPasswordModal from "../../../components/share/showEnterPasswordM
import showErrorModal from "../../../components/share/showErrorModal"; import showErrorModal from "../../../components/share/showErrorModal";
import shareService from "../../../services/share.service"; import shareService from "../../../services/share.service";
import { Share as ShareType } from "../../../types/share.type"; import { Share as ShareType } from "../../../types/share.type";
import toast from "../../../utils/toast.util";
export function getServerSideProps(context: GetServerSidePropsContext) { export function getServerSideProps(context: GetServerSidePropsContext) {
return { return {
@@ -28,12 +29,15 @@ const Share = ({ shareId }: { shareId: string }) => {
getFiles(); getFiles();
}) })
.catch((e) => { .catch((e) => {
if (e.response.data.error == "share_max_views_exceeded") { const { error } = e.response.data;
if (error == "share_max_views_exceeded") {
showErrorModal( showErrorModal(
modals, modals,
"Visitor limit exceeded", "Visitor limit exceeded",
"The visitor limit from this share has been exceeded." "The visitor limit from this share has been exceeded."
); );
} else {
toast.axiosError(e);
} }
}); });
}; };
@@ -85,7 +89,12 @@ const Share = ({ shareId }: { shareId: string }) => {
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />} {share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
</Group> </Group>
<FileList files={share?.files} share={share!} isLoading={!share} /> <FileList
files={share?.files}
setShare={setShare}
share={share!}
isLoading={!share}
/>
</> </>
); );
}; };

View File

@@ -1,94 +0,0 @@
import { Center, Stack, Text, Title } from "@mantine/core";
import { GetServerSidePropsContext } from "next";
import { useState } from "react";
export function getServerSideProps(context: GetServerSidePropsContext) {
const { shareId, fileId } = context.params!;
const mimeType = context.query.type as string;
return {
props: { shareId, fileId, mimeType },
};
}
const UnSupportedFile = () => {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10}>
<Title order={3}>Preview not supported</Title>
<Text>
A preview for thise file type is unsupported. Please download the file
to view it.
</Text>
</Stack>
</Center>
);
};
const FilePreview = ({
shareId,
fileId,
mimeType,
}: {
shareId: string;
fileId: string;
mimeType: string;
}) => {
const [isNotSupported, setIsNotSupported] = useState(false);
if (isNotSupported) return <UnSupportedFile />;
if (mimeType == "application/pdf") {
if (typeof window !== "undefined") {
window.location.href = `/api/shares/${shareId}/files/${fileId}?download=false`;
}
return null;
} else if (mimeType.startsWith("video/")) {
return (
<video
width="100%"
controls
onError={() => {
setIsNotSupported(true);
}}
>
<source src={`/api/shares/${shareId}/files/${fileId}?download=false`} />
</video>
);
} else if (mimeType.startsWith("image/")) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
onError={() => {
setIsNotSupported(true);
}}
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
alt={`${fileId}_preview`}
width="100%"
/>
);
} else if (mimeType.startsWith("audio/")) {
return (
<Center style={{ height: "70vh" }}>
<Stack align="center" spacing={10} style={{ width: "100%" }}>
<audio
controls
style={{ width: "100%" }}
onError={() => {
setIsNotSupported(true);
}}
>
<source
src={`/api/shares/${shareId}/files/${fileId}?download=false`}
/>
</audio>
</Stack>
</Center>
);
} else {
return <UnSupportedFile />;
}
};
export default FilePreview;

View File

@@ -59,7 +59,10 @@ const Upload = ({
setFileProgress(1); setFileProgress(1);
const chunks = Math.ceil(file.size / chunkSize); let chunks = Math.ceil(file.size / chunkSize);
// If the file is 0 bytes, we still need to upload 1 chunk
if (chunks == 0) chunks++;
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
const from = chunkIndex * chunkSize; const from = chunkIndex * chunkSize;
@@ -78,7 +81,7 @@ const Upload = ({
name: file.name, name: file.name,
}, },
chunkIndex, chunkIndex,
Math.ceil(file.size / chunkSize) chunks
) )
.then((response) => { .then((response) => {
fileId = response.id; fileId = response.id;
@@ -125,7 +128,7 @@ const Upload = ({
toast.error( toast.error(
`${fileErrorCount} file(s) failed to upload. Trying again.`, `${fileErrorCount} file(s) failed to upload. Trying again.`,
{ {
disallowClose: true, withCloseButton: false,
autoClose: false, autoClose: false,
} }
); );
@@ -173,7 +176,7 @@ const Upload = ({
"share.allowUnauthenticatedShares" "share.allowUnauthenticatedShares"
), ),
enableEmailRecepients: config.get( enableEmailRecepients: config.get(
"ENABLE_SHARE_EMAIL_RECIPIENTS" "email.enableShareEmailRecipients"
), ),
}, },
uploadFiles uploadFiles

View File

@@ -46,6 +46,12 @@ const isNewReleaseAvailable = async () => {
return response.tag_name.replace("v", "") != process.env.VERSION; return response.tag_name.replace("v", "") != process.env.VERSION;
}; };
const changeLogo = async (file: File) => {
const form = new FormData();
form.append("file", file);
await api.post("/configs/admin/logo", form);
};
export default { export default {
list, list,
getByCategory, getByCategory,
@@ -54,4 +60,5 @@ export default {
finishSetup, finishSetup,
sendTestEmail, sendTestEmail,
isNewReleaseAvailable, isNewReleaseAvailable,
changeLogo,
}; };

View File

@@ -53,7 +53,7 @@ const isShareIdAvailable = async (id: string): Promise<boolean> => {
}; };
const doesFileSupportPreview = (fileName: string) => { const doesFileSupportPreview = (fileName: string) => {
const mimeType = mime.contentType(fileName); const mimeType = (mime.contentType(fileName) || "").split(";")[0];
if (!mimeType) return false; if (!mimeType) return false;
@@ -61,6 +61,7 @@ const doesFileSupportPreview = (fileName: string) => {
mimeType.startsWith("video/"), mimeType.startsWith("video/"),
mimeType.startsWith("image/"), mimeType.startsWith("image/"),
mimeType.startsWith("audio/"), mimeType.startsWith("audio/"),
mimeType.startsWith("text/"),
mimeType == "application/pdf", mimeType == "application/pdf",
]; ];

View File

@@ -16,4 +16,14 @@ export default <MantineThemeOverride>{
], ],
}, },
primaryColor: "victoria", primaryColor: "victoria",
components: {
Modal: {
styles: (theme) => ({
title: {
fontSize: theme.fontSizes.lg,
fontWeight: 700,
},
}),
},
},
}; };

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.11.0", "version": "0.13.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",