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)

View File

@@ -1,26 +1,26 @@
# Using node slim because prisma ORM needs libc for ARM builds
# Stage 1: on frontend dependency change
FROM node:18-slim AS frontend-dependencies
FROM node:19-slim AS frontend-dependencies
WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Stage 2: on frontend change
FROM node:18-slim AS frontend-builder
FROM node:19-slim AS frontend-builder
WORKDIR /opt/app
COPY ./frontend .
COPY --from=frontend-dependencies /opt/app/node_modules ./node_modules
RUN npm run build
# Stage 3: on backend dependency change
FROM node:18-slim AS backend-dependencies
FROM node:19-slim AS backend-dependencies
WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
# Stage 4:on backend change
FROM node:18-slim AS backend-builder
FROM node:19-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app
COPY ./backend .
@@ -29,7 +29,7 @@ RUN npx prisma generate
RUN npm run build && npm prune --production
# Stage 5: Final image
FROM node:18-slim AS runner
FROM node:19-slim AS runner
ENV NODE_ENV=docker
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)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
<img src="https://user-images.githubusercontent.com/58886915/225038319-b2ef742c-3a74-4eb6-9689-4207a36842a4.png" width="700"/>
## ⌨️ Setup
@@ -33,7 +33,7 @@ The website is now listening on `http://localhost:3000`, have fun with Pingvin S
Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 14
- [Node.js](https://nodejs.org/en/download/) >= 16
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) for running Pingvin Share in the background
@@ -96,18 +96,7 @@ docker compose up -d
### Custom branding
#### Name
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
You can change the name and the logo of the app by visiting the admin configuration page.
## 🖤 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",
"version": "0.11.0",
"version": "0.13.0",
"scripts": {
"build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch",
@@ -13,65 +13,68 @@
"seed": "ts-node prisma/seed/config.seed.ts"
},
"dependencies": {
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^10.0.1",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0",
"@nestjs/common": "^9.3.9",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.3.9",
"@nestjs/jwt": "^10.0.2",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.3.9",
"@nestjs/schedule": "^2.2.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/throttler": "^3.1.0",
"@prisma/client": "^4.8.1",
"@nestjs/throttler": "^4.0.0",
"@prisma/client": "^4.11.0",
"archiver": "^5.3.1",
"argon2": "^0.30.3",
"body-parser": "^1.20.1",
"body-parser": "^1.20.2",
"clamscan": "^2.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"class-validator": "^0.14.0",
"content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"nodemailer": "^6.9.0",
"nodemailer": "^6.9.1",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"qrcode-svg": "^1.1.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^4.0.4",
"rimraf": "^4.4.0",
"rxjs": "^7.8.0",
"sharp": "^0.31.3",
"ts-node": "^10.9.1"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",
"@nestjs/cli": "^9.2.0",
"@nestjs/schematics": "^9.0.4",
"@nestjs/testing": "^9.2.1",
"@nestjs/testing": "^9.3.9",
"@types/archiver": "^5.3.1",
"@types/clamscan": "^2.0.4",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.15",
"@types/express": "^4.17.17",
"@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/passport-jwt": "^3.0.8",
"@types/qrcode-svg": "^1.1.1",
"@types/sharp": "^0.31.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"cross-env": "^7.0.3",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2",
"prettier": "^2.8.2",
"prisma": "^4.9.0",
"prettier": "^2.8.4",
"prisma": "^4.11.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.2",
"tsconfig-paths": "4.1.2",
"typescript": "^4.9.4",
"typescript": "^4.9.5",
"wait-on": "^7.0.1"
}
}

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
import {
Body,
Controller,
FileTypeValidator,
Get,
Param,
ParseFilePipe,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.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 { TestEmailDTO } from "./dto/testEmail.dto";
import UpdateConfigDTO from "./dto/updateConfig.dto";
import { LogoService } from "./logo.service";
@Controller("configs")
export class ConfigController {
constructor(
private configService: ConfigService,
private logoService: LogoService,
private emailService: EmailService
) {}
@@ -51,4 +58,18 @@ export class ConfigController {
async testEmail(@Body() { email }: TestEmailDTO) {
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 { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service";
import { LogoService } from "./logo.service";
@Global()
@Module({
@@ -16,6 +17,7 @@ import { ConfigService } from "./config.service";
inject: [PrismaService],
},
ConfigService,
LogoService,
],
controllers: [ConfigController],
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 * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service";
@@ -6,6 +10,7 @@ import { ConfigService } from "src/config/config.service";
@Injectable()
export class EmailService {
constructor(private config: ConfigService) {}
private readonly logger = new Logger(EmailService.name);
getTransporter() {
if (!this.config.get("smtp.enabled"))
@@ -22,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(
recipientEmail: string,
shareId: string,
@@ -32,34 +53,28 @@ export class EmailService {
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.shareRecipientsSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.shareRecipientsSubject"),
this.config
.get("email.shareRecipientsMessage")
.replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl),
});
.replaceAll("{shareUrl}", shareUrl)
);
}
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.reverseShareSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.reverseShareSubject"),
this.config
.get("email.reverseShareMessage")
.replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl),
});
.replaceAll("{shareUrl}", shareUrl)
);
}
async sendResetPasswordEmail(recipientEmail: string, token: string) {
@@ -67,47 +82,42 @@ export class EmailService {
"general.appUrl"
)}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.resetPasswordSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.resetPasswordSubject"),
this.config
.get("email.resetPasswordMessage")
.replaceAll("{url}", resetPasswordUrl),
});
.replaceAll("\\n", "\n")
.replaceAll("{url}", resetPasswordUrl)
);
}
async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.inviteSubject"),
text: this.config
await this.sendMail(
recipientEmail,
this.config.get("email.inviteSubject"),
this.config
.get("email.inviteMessage")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password),
});
.replaceAll("{password}", password)
);
}
async sendTestMail(recipientEmail: string) {
try {
await this.getTransporter().sendMail({
await this.getTransporter()
.sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
})
.catch((e) => {
this.logger.error(e);
throw new InternalServerErrorException(e.message);
});
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e.message);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,15 @@ const { version } = require('./package.json');
const withPWA = require("next-pwa")({
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({

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-frontend",
"version": "0.11.0",
"version": "0.13.0",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -9,43 +9,44 @@
"format": "prettier --write \"src/**/*.ts*\""
},
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.10.0",
"@mantine/dropzone": "^5.10.0",
"@mantine/form": "^5.10.0",
"@mantine/hooks": "^5.10.0",
"@mantine/modals": "^5.10.0",
"@mantine/next": "^5.10.0",
"@mantine/notifications": "^5.10.0",
"axios": "^1.2.2",
"@mantine/core": "^6.0.1",
"@mantine/dropzone": "^6.0.1",
"@mantine/form": "^6.0.1",
"@mantine/hooks": "^6.0.1",
"@mantine/modals": "^6.0.1",
"@mantine/next": "^6.0.1",
"@mantine/notifications": "^6.0.1",
"axios": "^1.3.4",
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.11.2",
"jose": "^4.13.1",
"jwt-decode": "^3.1.2",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"next": "^13.1.2",
"next": "^13.2.4",
"next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.5",
"next-pwa": "^5.6.0",
"p-limit": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.7.1",
"yup": "^0.32.11"
"react-icons": "^4.8.0",
"sharp": "^0.31.3",
"yup": "^1.0.2"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"axios": "^1.2.2",
"eslint": "8.31.0",
"eslint-config-next": "^13.1.2",
"eslint-config-prettier": "^8.6.0",
"prettier": "^2.8.2",
"@types/node": "18.15.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"axios": "^1.3.4",
"eslint": "8.35.0",
"eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.7.0",
"prettier": "^2.8.4",
"tar": "^6.1.13",
"typescript": "^4.9.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,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
@@ -27,7 +26,7 @@ const showEnableTotpModal = (
}
) => {
return modals.openModal({
title: <Title order={4}>Enable TOTP</Title>,
title: "Enable TOTP",
children: (
<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,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -19,7 +18,7 @@ const showCreateUserModal = (
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Create user</Title>,
title: "Create user",
children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
),
@@ -79,13 +78,12 @@ const Body = ({
})}
/>
)}
{form.values.setPasswordManually ||
(!smtpEnabled && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
))}
{(form.values.setPasswordManually || !smtpEnabled) && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
)}
<Switch
styles={{
body: {

View File

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

View File

@@ -32,11 +32,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().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({
@@ -79,8 +74,8 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
router.replace(redirectPath);
})
.catch((error) => {
if (error?.response?.data?.message == "Login token expired") {
toast.error("Login token expired");
if (error?.response?.data?.error == "share_password_required") {
toast.axiosError(error);
// Refresh the page to start over
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 {
ActionIcon,
Box,
Group,
Skeleton,
Stack,
@@ -8,9 +9,7 @@ import {
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import mime from "mime-types";
import Link from "next/link";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
import useConfig from "../../hooks/config.hook";
import shareService from "../../services/share.service";
@@ -18,13 +17,17 @@ import { FileMetaData } from "../../types/File.type";
import { Share } from "../../types/share.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import toast from "../../utils/toast.util";
import TableSortIcon, { TableSort } from "../core/SortIcon";
import showFilePreviewModal from "./modals/showFilePreviewModal";
const FileList = ({
files,
setShare,
share,
isLoading,
}: {
files?: FileMetaData[];
setShare: Dispatch<SetStateAction<Share | undefined>>;
share: Share;
isLoading: boolean;
}) => {
@@ -32,6 +35,32 @@ const FileList = ({
const config = useConfig();
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 link = `${config.get("general.appUrl")}/api/shares/${
share.id
@@ -52,55 +81,70 @@ const FileList = ({
}
};
useEffect(sortFiles, [sort]);
return (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
<td>
<Group position="right">
{shareService.doesFileSupportPreview(file.name) && (
<Box sx={{ display: "block", overflowX: "auto" }}>
<Table>
<thead>
<tr>
<th>
<Group spacing="xs">
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>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: files!.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
<td>
<Group position="right">
{shareService.doesFileSupportPreview(file.name) && (
<ActionIcon
onClick={() =>
showFilePreviewModal(share.id, file, modals)
}
size={25}
>
<TbEye />
</ActionIcon>
)}
{!share.hasPassword && (
<ActionIcon
size={25}
onClick={() => copyFileLink(file)}
>
<TbLink />
</ActionIcon>
)}
<ActionIcon
component={Link}
href={`/share/${share.id}/preview/${
file.id
}?type=${mime.contentType(file.name)}`}
target="_blank"
size={25}
onClick={async () => {
await shareService.downloadFile(share.id, file.id);
}}
>
<TbEye />
<TbDownload />
</ActionIcon>
)}
{!share.hasPassword && (
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
<TbLink />
</ActionIcon>
)}
<ActionIcon
size={25}
onClick={async () => {
await shareService.downloadFile(share.id, file.id);
}}
>
<TbDownload />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
);
};

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}
value={size}
onChange={(value) => {
setSize(value!);
onChange(unitAndSizeToByte(unit, value!));
if (value) {
setSize(value);
onChange(unitAndSizeToByte(unit, value));
}
}}
/>
</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 { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
@@ -14,11 +14,7 @@ const showCompletedReverseShareModal = (
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: (
<Stack align="stretch" spacing={0}>
<Title order={4}>Reverse share link</Title>
</Stack>
),
title: "Reverse share link",
children: <Body link={link} getReverseShares={getReverseShares} />,
});
};

View File

@@ -8,7 +8,6 @@ import {
Stack,
Switch,
Text,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
@@ -25,7 +24,7 @@ const showCreateReverseShareModal = (
getReverseShares: () => void
) => {
return modals.openModal({
title: <Title order={4}>Create reverse share</Title>,
title: "Create reverse share",
children: (
<Body
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,57 +1,52 @@
import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core";
import { Button, PasswordInput, Stack, Text } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react";
const showEnterPasswordModal = (
modals: ModalsContextProps,
submitCallback: any
submitCallback: (password: string) => Promise<void>
) => {
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: (
<>
<Title order={4}>Password required</Title>
<Text size="sm">
This access this share please enter the password for the share.
</Text>
</>
),
title: "Password required",
children: <Body submitCallback={submitCallback} />,
});
};
const Body = ({ submitCallback }: { submitCallback: any }) => {
const Body = ({
submitCallback,
}: {
submitCallback: (password: string) => Promise<void>;
}) => {
const [password, setPassword] = useState("");
const [passwordWrong, setPasswordWrong] = useState(false);
return (
<>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder="Password"
error={passwordWrong && "Wrong password"}
onFocus={() => setPasswordWrong(false)}
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
<Button
onClick={() =>
submitCallback(password)
.then((res: any) => res)
.catch((e: any) => {
const error = e.response.data.message;
if (error == "Wrong password") {
setPasswordWrong(true);
}
})
}
>
Submit
</Button>
</Stack>
</>
<Stack align="stretch">
<Text size="sm">
This access this share please enter the password for the share.
</Text>
<form
onSubmit={(e) => {
e.preventDefault();
submitCallback(password);
}}
>
<Stack>
<PasswordInput
variant="filled"
placeholder="Password"
error={passwordWrong && "Wrong password"}
onFocus={() => setPasswordWrong(false)}
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
<Button type="submit">Submit</Button>
</Stack>
</form>
</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 { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router";
@@ -12,7 +12,7 @@ const showErrorModal = (
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: <Title order={4}>{title}</Title>,
title: title,
children: <Body text={text} />,
});

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {
} from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import { Notifications } from "@mantine/notifications";
import axios from "axios";
import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next";
@@ -76,40 +76,39 @@ function App({ Component, pageProps }: AppProps) {
toggleColorScheme={toggleColorScheme}
>
<GlobalStyle />
<NotificationsProvider>
<ModalsProvider>
<ConfigContext.Provider
<Notifications />
<ModalsProvider>
<ConfigContext.Provider
value={{
configVariables,
refresh: async () => {
setConfigVariables(await configService.list());
},
}}
>
<UserContext.Provider
value={{
configVariables,
refresh: async () => {
setConfigVariables(await configService.list());
user,
refreshUser: async () => {
const user = await userService.getCurrentUser();
setUser(user);
return user;
},
}}
>
<UserContext.Provider
value={{
user,
refreshUser: async () => {
const user = await userService.getCurrentUser();
setUser(user);
return user;
},
}}
>
{excludeDefaultLayoutRoutes.includes(route) ? (
<Component {...pageProps} />
) : (
<>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</>
)}
</UserContext.Provider>
</ConfigContext.Provider>
</ModalsProvider>
</NotificationsProvider>
{excludeDefaultLayoutRoutes.includes(route) ? (
<Component {...pageProps} />
) : (
<>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</>
)}
</UserContext.Provider>
</ConfigContext.Provider>
</ModalsProvider>
</ColorSchemeProvider>
</MantineProvider>
);

View File

@@ -12,14 +12,8 @@ export default class _Document extends Document {
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link
rel="apple-touch-icon"
href="/img/icons/icon-white-128x128.png"
/>
<link rel="apple-touch-icon" href="/img/icons/icon-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="theme-color" content="#46509e" />
</Head>

View File

@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput";
import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader";
import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar";
import LogoConfigInput from "../../../components/admin/configuration/LogoConfigInput";
import TestEmailButton from "../../../components/admin/configuration/TestEmailButton";
import CenterLoader from "../../../components/core/CenterLoader";
import Meta from "../../../components/Meta";
@@ -36,22 +37,38 @@ export default function AppShellDemo() {
const isMobile = useMediaQuery("(max-width: 560px)");
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 [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
const [logo, setLogo] = useState<File | null>(null);
const saveConfigVariables = async () => {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
config.refresh();
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
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
config.refresh();
}
};
const updateConfigVariable = (configVariable: UpdateConfig) => {
@@ -129,6 +146,9 @@ export default function AppShellDemo() {
</Box>
</Group>
))}
{categoryId == "general" && (
<LogoConfigInput logo={logo} setLogo={setLogo} />
)}
</Stack>
<Group mt="lg" position="right">
{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",
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 mt="lg">How to you want to continue?</Text>
<Stack>
<Button href="/admin/config" component={Link}>
<Button href="/admin/config/general" component={Link}>
Customize configuration
</Button>
<Button href="/" component={Link} variant="light">

View File

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

View File

@@ -9,6 +9,7 @@ import showEnterPasswordModal from "../../../components/share/showEnterPasswordM
import showErrorModal from "../../../components/share/showErrorModal";
import shareService from "../../../services/share.service";
import { Share as ShareType } from "../../../types/share.type";
import toast from "../../../utils/toast.util";
export function getServerSideProps(context: GetServerSidePropsContext) {
return {
@@ -28,12 +29,15 @@ const Share = ({ shareId }: { shareId: string }) => {
getFiles();
})
.catch((e) => {
if (e.response.data.error == "share_max_views_exceeded") {
const { error } = e.response.data;
if (error == "share_max_views_exceeded") {
showErrorModal(
modals,
"Visitor limit 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} />}
</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);
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++) {
const from = chunkIndex * chunkSize;
@@ -78,7 +81,7 @@ const Upload = ({
name: file.name,
},
chunkIndex,
Math.ceil(file.size / chunkSize)
chunks
)
.then((response) => {
fileId = response.id;
@@ -125,7 +128,7 @@ const Upload = ({
toast.error(
`${fileErrorCount} file(s) failed to upload. Trying again.`,
{
disallowClose: true,
withCloseButton: false,
autoClose: false,
}
);
@@ -173,7 +176,7 @@ const Upload = ({
"share.allowUnauthenticatedShares"
),
enableEmailRecepients: config.get(
"ENABLE_SHARE_EMAIL_RECIPIENTS"
"email.enableShareEmailRecipients"
),
},
uploadFiles

View File

@@ -46,6 +46,12 @@ const isNewReleaseAvailable = async () => {
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 {
list,
getByCategory,
@@ -54,4 +60,5 @@ export default {
finishSetup,
sendTestEmail,
isNewReleaseAvailable,
changeLogo,
};

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share",
"version": "0.11.0",
"version": "0.13.0",
"scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",