Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5c919110 | ||
|
|
008df06b5c | ||
|
|
cd9d828686 | ||
|
|
233c26e5cf | ||
|
|
91a6b3f716 | ||
|
|
0a2b7b1243 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
|||||||
|
## [0.9.0](https://github.com/stonith404/pingvin-share/compare/v0.8.0...v0.9.0) (2023-01-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* direct file link ([008df06](https://github.com/stonith404/pingvin-share/commit/008df06b5cf48872d4dd68df813370596a4fd468))
|
||||||
|
* file preview ([91a6b3f](https://github.com/stonith404/pingvin-share/commit/91a6b3f716d37d7831e17a7be1cdb35cb23da705))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* improve send test email UX ([233c26e](https://github.com/stonith404/pingvin-share/commit/233c26e5cfde59e7d51023ef9901dec2b84a4845))
|
||||||
|
|
||||||
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
|
## [0.8.0](https://github.com/stonith404/pingvin-share/compare/v0.7.0...v0.8.0) (2023-01-26)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
response = this.addTokensToResponse(
|
||||||
|
|||||||
@@ -59,11 +59,16 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendTestMail(recipientEmail: string) {
|
async sendTestMail(recipientEmail: string) {
|
||||||
|
try {
|
||||||
await this.getTransporter().sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Test email",
|
subject: "Test email",
|
||||||
text: "This is a test email",
|
text: "This is a test email",
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new InternalServerErrorException(e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ import {
|
|||||||
import { SkipThrottle } from "@nestjs/throttler";
|
import { SkipThrottle } from "@nestjs/throttler";
|
||||||
import * as contentDisposition from "content-disposition";
|
import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
|
||||||
import { FileDownloadGuard } from "src/file/guard/fileDownload.guard";
|
|
||||||
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
import { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
|
||||||
import { FileService } from "./file.service";
|
import { FileService } from "./file.service";
|
||||||
|
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||||
|
|
||||||
@Controller("shares/:shareId/files")
|
@Controller("shares/:shareId/files")
|
||||||
export class FileController {
|
export class FileController {
|
||||||
@@ -44,30 +42,8 @@ export class FileController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId/download")
|
|
||||||
@UseGuards(ShareSecurityGuard)
|
|
||||||
async getFileDownloadUrl(
|
|
||||||
@Param("shareId") shareId: string,
|
|
||||||
@Param("fileId") fileId: string
|
|
||||||
) {
|
|
||||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
|
||||||
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("zip/download")
|
|
||||||
@UseGuards(ShareSecurityGuard)
|
|
||||||
async getZipArchiveDownloadURL(
|
|
||||||
@Param("shareId") shareId: string,
|
|
||||||
@Param("fileId") fileId: string
|
|
||||||
) {
|
|
||||||
const url = this.fileService.getFileDownloadUrl(shareId, fileId);
|
|
||||||
|
|
||||||
return { url };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("zip")
|
@Get("zip")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
@@ -75,25 +51,32 @@ 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": `attachment ; filename="pingvin-share-${shareId}.zip"`,
|
"Content-Disposition": contentDisposition(`pingvin-share-${shareId}.zip`),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(zip);
|
return new StreamableFile(zip);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId")
|
@Get(":fileId")
|
||||||
@UseGuards(FileDownloadGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
@Param("fileId") fileId: string
|
@Param("fileId") fileId: string,
|
||||||
|
@Query("download") download = "true"
|
||||||
) {
|
) {
|
||||||
const file = await this.fileService.get(shareId, fileId);
|
const file = await this.fileService.get(shareId, fileId);
|
||||||
res.set({
|
|
||||||
|
const headers = {
|
||||||
"Content-Type": file.metaData.mimeType,
|
"Content-Type": file.metaData.mimeType,
|
||||||
"Content-Length": file.metaData.size,
|
"Content-Length": file.metaData.size,
|
||||||
"Content-Disposition": contentDisposition(file.metaData.name),
|
};
|
||||||
});
|
|
||||||
|
if (download === "true") {
|
||||||
|
headers["Content-Disposition"] = contentDisposition(file.metaData.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set(headers);
|
||||||
|
|
||||||
return new StreamableFile(file.file);
|
return new StreamableFile(file.file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,38 +135,4 @@ export class FileService {
|
|||||||
getZip(shareId: string) {
|
getZip(shareId: string) {
|
||||||
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
|
||||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
|
||||||
|
|
||||||
return `${this.config.get(
|
|
||||||
"APP_URL"
|
|
||||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
generateFileDownloadToken(shareId: string, fileId: string) {
|
|
||||||
if (fileId == "zip") fileId = undefined;
|
|
||||||
|
|
||||||
return this.jwtService.sign(
|
|
||||||
{
|
|
||||||
shareId,
|
|
||||||
fileId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expiresIn: "10min",
|
|
||||||
secret: this.config.get("JWT_SECRET"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyFileDownloadToken(shareId: string, token: string) {
|
|
||||||
try {
|
|
||||||
const claims = this.jwtService.verify(token, {
|
|
||||||
secret: this.config.get("JWT_SECRET"),
|
|
||||||
});
|
|
||||||
return claims.shareId == shareId;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
|
||||||
import { FileService } from "src/file/file.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FileDownloadGuard implements CanActivate {
|
|
||||||
constructor(private fileService: FileService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const token = request.query.token as string;
|
|
||||||
const { shareId } = request.params;
|
|
||||||
|
|
||||||
return this.fileService.verifyFileDownloadToken(shareId, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
65
backend/src/file/guard/fileSecurity.guard.ts
Normal file
65
backend/src/file/guard/fileSecurity.guard.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import * as moment from "moment";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||||
|
import { ShareService } from "src/share/share.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileSecurityGuard extends ShareSecurityGuard {
|
||||||
|
constructor(
|
||||||
|
private _shareService: ShareService,
|
||||||
|
private _prisma: PrismaService
|
||||||
|
) {
|
||||||
|
super(_shareService, _prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
|
request.params,
|
||||||
|
"shareId"
|
||||||
|
)
|
||||||
|
? request.params.shareId
|
||||||
|
: request.params.id;
|
||||||
|
|
||||||
|
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||||
|
|
||||||
|
const share = await this._prisma.share.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: { security: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is no share token the user requests a file directly
|
||||||
|
if (!shareToken) {
|
||||||
|
if (
|
||||||
|
!share ||
|
||||||
|
(moment().isAfter(share.expiration) &&
|
||||||
|
!moment(share.expiration).isSame(0))
|
||||||
|
) {
|
||||||
|
throw new NotFoundException("File not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.security?.password)
|
||||||
|
throw new ForbiddenException("This share is password protected");
|
||||||
|
|
||||||
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Maximum views exceeded",
|
||||||
|
"share_max_views_exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._shareService.increaseViewCount(share);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
|
|||||||
shareExpiration: Date;
|
shareExpiration: Date;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => OmitType(MyShareDTO, ["recipients"] as const))
|
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||||
share: Omit<MyShareDTO, "recipients" | "files" | "from" | "fromList">;
|
share: Omit<
|
||||||
|
MyShareDTO,
|
||||||
|
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||||
|
>;
|
||||||
|
|
||||||
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
||||||
return partial.map((part) =>
|
return partial.map((part) =>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class ShareDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
hasPassword: boolean;
|
||||||
|
|
||||||
from(partial: Partial<ShareDTO>) {
|
from(partial: Partial<ShareDTO>) {
|
||||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Reflector } from "@nestjs/core";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
@@ -14,14 +13,13 @@ import { ShareService } from "src/share/share.service";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareSecurityGuard implements CanActivate {
|
export class ShareSecurityGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
|
||||||
private shareService: ShareService,
|
private shareService: ShareService,
|
||||||
private prisma: PrismaService
|
private prisma: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareToken = request.get("X-Share-Token");
|
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
"shareId"
|
"shareId"
|
||||||
@@ -29,6 +27,8 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
? request.params.shareId
|
? request.params.shareId
|
||||||
: request.params.id;
|
: request.params.id;
|
||||||
|
|
||||||
|
const shareToken = request.cookies[`share_${shareId}_token`];
|
||||||
|
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id: shareId },
|
where: { id: shareId },
|
||||||
include: { security: true },
|
include: { security: true },
|
||||||
@@ -37,7 +37,7 @@ export class ShareSecurityGuard implements CanActivate {
|
|||||||
if (
|
if (
|
||||||
!share ||
|
!share ||
|
||||||
(moment().isAfter(share.expiration) &&
|
(moment().isAfter(share.expiration) &&
|
||||||
moment(share.expiration).unix() !== 0)
|
!moment(share.expiration).isSame(0))
|
||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -34,12 +33,6 @@ export class ShareTokenSecurity implements CanActivate {
|
|||||||
)
|
)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
if (share.security?.maxViews && share.security.maxViews <= share.views)
|
|
||||||
throw new ForbiddenException(
|
|
||||||
"Maximum views exceeded",
|
|
||||||
"share_max_views_exceeded"
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request, Response } from "express";
|
||||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
import { ConfigService } from "src/config/config.service";
|
|
||||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||||
import { MyShareDTO } from "./dto/myShare.dto";
|
import { MyShareDTO } from "./dto/myShare.dto";
|
||||||
import { ShareDTO } from "./dto/share.dto";
|
import { ShareDTO } from "./dto/share.dto";
|
||||||
@@ -27,10 +27,7 @@ import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
|
|||||||
import { ShareService } from "./share.service";
|
import { ShareService } from "./share.service";
|
||||||
@Controller("shares")
|
@Controller("shares")
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(
|
constructor(private shareService: ShareService) {}
|
||||||
private shareService: ShareService,
|
|
||||||
private config: ConfigService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@@ -88,10 +85,20 @@ export class ShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(20, 5 * 60)
|
||||||
@UseGuards(ShareTokenSecurity)
|
@UseGuards(ShareTokenSecurity)
|
||||||
@Post(":id/token")
|
@Post(":id/token")
|
||||||
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
|
async getShareToken(
|
||||||
return this.shareService.getShareToken(id, body.password);
|
@Param("id") id: string,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
@Body() body: SharePasswordDto
|
||||||
|
) {
|
||||||
|
const token = await this.shareService.getShareToken(id, body.password);
|
||||||
|
response.cookie(`share_${id}_token`, token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,12 +204,13 @@ export class ShareService {
|
|||||||
return sharesWithEmailRecipients;
|
return sharesWithEmailRecipients;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string): Promise<any> {
|
||||||
const share = await this.prisma.share.findUnique({
|
const share = await this.prisma.share.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
|
security: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,8 +219,10 @@ export class ShareService {
|
|||||||
|
|
||||||
if (!share || !share.uploadLocked)
|
if (!share || !share.uploadLocked)
|
||||||
throw new NotFoundException("Share not found");
|
throw new NotFoundException("Share not found");
|
||||||
|
return {
|
||||||
return share as any;
|
...share,
|
||||||
|
hasPassword: share.security?.password ? true : false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaData(id: string) {
|
async getMetaData(id: string) {
|
||||||
@@ -273,12 +276,20 @@ export class ShareService {
|
|||||||
if (
|
if (
|
||||||
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.security?.maxViews && share.security.maxViews <= share.views) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Maximum views exceeded",
|
||||||
|
"share_max_views_exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const token = await this.generateShareToken(shareId);
|
const token = await this.generateShareToken(shareId);
|
||||||
await this.increaseViewCount(share);
|
await this.increaseViewCount(share);
|
||||||
return { token };
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateShareToken(shareId: string) {
|
async generateShareToken(shareId: string) {
|
||||||
|
|||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"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.11.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
@@ -2656,6 +2658,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mime-types": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/minimatch": {
|
"node_modules/@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
@@ -9913,6 +9921,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.181.tgz",
|
||||||
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
"integrity": "sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag=="
|
||||||
},
|
},
|
||||||
|
"@types/mime-types": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/minimatch": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-frontend",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"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.11.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"next": "^13.1.2",
|
"next": "^13.1.2",
|
||||||
"next-cookies": "^2.0.3",
|
"next-cookies": "^2.0.3",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"yup": "^0.32.11"
|
"yup": "^0.32.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const AdminConfigTable = () => {
|
|||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const isMobile = useMediaQuery("(max-width: 560px)");
|
const isMobile = useMediaQuery("(max-width: 560px)");
|
||||||
|
|
||||||
let updatedConfigVariables: UpdateConfig[] = [];
|
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
|
||||||
|
UpdateConfig[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||||
const index = updatedConfigVariables.findIndex(
|
const index = updatedConfigVariables.findIndex(
|
||||||
@@ -38,7 +40,7 @@ const AdminConfigTable = () => {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
updatedConfigVariables[index] = configVariable;
|
updatedConfigVariables[index] = configVariable;
|
||||||
} else {
|
} else {
|
||||||
updatedConfigVariables.push(configVariable);
|
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +62,26 @@ const AdminConfigTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveConfigVariables = async () => {
|
||||||
|
if (config.get("SETUP_STATUS") == "REGISTERED") {
|
||||||
|
await configService
|
||||||
|
.updateMany(updatedConfigVariables)
|
||||||
|
.then(async () => {
|
||||||
|
await configService.finishSetup();
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
} else {
|
||||||
|
await configService
|
||||||
|
.updateMany(updatedConfigVariables)
|
||||||
|
.then(() => {
|
||||||
|
setUpdatedConfigVariables([]);
|
||||||
|
toast.success("Configurations updated successfully");
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getConfigVariables();
|
getConfigVariables();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -102,7 +124,10 @@ const AdminConfigTable = () => {
|
|||||||
))}
|
))}
|
||||||
{category == "smtp" && (
|
{category == "smtp" && (
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<TestEmailButton />
|
<TestEmailButton
|
||||||
|
configVariablesChanged={updatedConfigVariables.length != 0}
|
||||||
|
saveConfigVariables={saveConfigVariables}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -110,29 +135,7 @@ const AdminConfigTable = () => {
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
<Button
|
<Button onClick={saveConfigVariables}>Save</Button>
|
||||||
onClick={() => {
|
|
||||||
if (config.get("SETUP_STATUS") == "REGISTERED") {
|
|
||||||
configService
|
|
||||||
.updateMany(updatedConfigVariables)
|
|
||||||
.then(async () => {
|
|
||||||
await configService.finishSetup();
|
|
||||||
window.location.reload();
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
|
||||||
} else {
|
|
||||||
configService
|
|
||||||
.updateMany(updatedConfigVariables)
|
|
||||||
.then(() => {
|
|
||||||
updatedConfigVariables = [];
|
|
||||||
toast.success("Configurations updated successfully");
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,69 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button, Stack, Text, Textarea } from "@mantine/core";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { useState } from "react";
|
||||||
import useUser from "../../../hooks/user.hook";
|
import useUser from "../../../hooks/user.hook";
|
||||||
import configService from "../../../services/config.service";
|
import configService from "../../../services/config.service";
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
const TestEmailButton = () => {
|
const TestEmailButton = ({
|
||||||
|
configVariablesChanged,
|
||||||
|
saveConfigVariables,
|
||||||
|
}: {
|
||||||
|
configVariablesChanged: boolean;
|
||||||
|
saveConfigVariables: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
await configService
|
||||||
|
.sendTestEmail(user!.email)
|
||||||
|
.then(() => toast.success("Email sent successfully"))
|
||||||
|
.catch((e) =>
|
||||||
|
modals.openModal({
|
||||||
|
title: "Failed to send email",
|
||||||
|
children: (
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Text size="sm">
|
||||||
|
While sending the test email, the following error occurred:
|
||||||
|
</Text>
|
||||||
|
<Textarea minRows={4} readOnly value={e.response.data.message} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
configService
|
if (!configVariablesChanged) {
|
||||||
.sendTestEmail(user!.email)
|
setIsLoading(true);
|
||||||
.then(() => toast.success("Email sent successfully"))
|
await sendTestEmail();
|
||||||
.catch(() =>
|
setIsLoading(false);
|
||||||
toast.error(
|
} else {
|
||||||
"Failed to send the email. Please check the backend logs for more information."
|
modals.openConfirmModal({
|
||||||
)
|
title: "Save configuration",
|
||||||
)
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
To continue you need to save the configuration first. Do you
|
||||||
|
want to save the configuration and send the test email?
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: "Save and send", cancel: "Cancel" },
|
||||||
|
onConfirm: async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await saveConfigVariables();
|
||||||
|
await sendTestEmail();
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Send test email
|
Send test email
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
13
frontend/src/components/core/CenterLoader.tsx
Normal file
13
frontend/src/components/core/CenterLoader.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Center, Loader, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
const CenterLoader = () => {
|
||||||
|
return (
|
||||||
|
<Center style={{ height: "70vh" }}>
|
||||||
|
<Stack align="center" spacing={10}>
|
||||||
|
<Loader />
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CenterLoader;
|
||||||
@@ -1,18 +1,57 @@
|
|||||||
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
|
import {
|
||||||
import { TbCircleCheck, TbDownload } from "react-icons/tb";
|
ActionIcon,
|
||||||
import shareService from "../../services/share.service";
|
Group,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import mime from "mime-types";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import shareService from "../../services/share.service";
|
||||||
|
import { FileMetaData } from "../../types/File.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";
|
||||||
|
|
||||||
const FileList = ({
|
const FileList = ({
|
||||||
files,
|
files,
|
||||||
shareId,
|
share,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
files?: any[];
|
files?: FileMetaData[];
|
||||||
shareId: string;
|
share: Share;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
const config = useConfig();
|
||||||
|
const modals = useModals();
|
||||||
|
|
||||||
|
const copyFileLink = (file: FileMetaData) => {
|
||||||
|
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${
|
||||||
|
file.id
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
clipboard.copy(link);
|
||||||
|
toast.success("Your file link was copied to the keyboard.");
|
||||||
|
} else {
|
||||||
|
modals.openModal({
|
||||||
|
title: "File link",
|
||||||
|
children: (
|
||||||
|
<Stack align="stretch">
|
||||||
|
<TextInput variant="filled" value={link} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -28,24 +67,35 @@ const FileList = ({
|
|||||||
: files!.map((file) => (
|
: files!.map((file) => (
|
||||||
<tr key={file.name}>
|
<tr key={file.name}>
|
||||||
<td>{file.name}</td>
|
<td>{file.name}</td>
|
||||||
<td>{byteToHumanSizeString(file.size)}</td>
|
<td>{byteToHumanSizeString(parseInt(file.size))}</td>
|
||||||
<td>
|
<td>
|
||||||
{file.uploadingState ? (
|
<Group position="right">
|
||||||
file.uploadingState != "finished" ? (
|
{shareService.doesFileSupportPreview(file.name) && (
|
||||||
<Loader size={22} />
|
<ActionIcon
|
||||||
) : (
|
component={Link}
|
||||||
<TbCircleCheck color="green" size={22} />
|
href={`/share/${share.id}/preview/${
|
||||||
)
|
file.id
|
||||||
) : (
|
}?type=${mime.contentType(file.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
size={25}
|
||||||
|
>
|
||||||
|
<TbEye />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{!share.hasPassword && (
|
||||||
|
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||||
|
<TbLink />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={25}
|
size={25}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await shareService.downloadFile(shareId, file.id);
|
await shareService.downloadFile(share.id, file.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TbDownload />
|
<TbDownload />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
</Group>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
LoadingOverlay,
|
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,6 +17,7 @@ import { useRouter } from "next/router";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
|
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
|
||||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||||
|
import CenterLoader from "../../components/core/CenterLoader";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../components/Meta";
|
||||||
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
|
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
@@ -50,7 +50,7 @@ const MyShares = () => {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
} else {
|
} else {
|
||||||
if (!reverseShares) return <LoadingOverlay visible />;
|
if (!reverseShares) return <CenterLoader />;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title="My shares" />
|
<Meta title="My shares" />
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Box, Group, Text, Title } from "@mantine/core";
|
|||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../../components/Meta";
|
||||||
import DownloadAllButton from "../../components/share/DownloadAllButton";
|
import DownloadAllButton from "../../../components/share/DownloadAllButton";
|
||||||
import FileList from "../../components/share/FileList";
|
import FileList from "../../../components/share/FileList";
|
||||||
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
|
import showEnterPasswordModal from "../../../components/share/showEnterPasswordModal";
|
||||||
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";
|
||||||
|
|
||||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +85,7 @@ 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} shareId={shareId} isLoading={!share} />
|
<FileList files={share?.files} share={share!} isLoading={!share} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
92
frontend/src/pages/share/[shareId]/preview/[fileId].tsx
Normal file
92
frontend/src/pages/share/[shareId]/preview/[fileId].tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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") {
|
||||||
|
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;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { setCookie } from "cookies-next";
|
import { setCookie } from "cookies-next";
|
||||||
|
import mime from "mime-types";
|
||||||
import { FileUploadResponse } from "../types/File.type";
|
import { FileUploadResponse } from "../types/File.type";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateShare,
|
CreateShare,
|
||||||
MyReverseShare,
|
MyReverseShare,
|
||||||
@@ -27,21 +29,11 @@ const completeShare = async (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const get = async (id: string): Promise<Share> => {
|
const get = async (id: string): Promise<Share> => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${id}_token`);
|
return (await api.get(`shares/${id}`)).data;
|
||||||
return (
|
|
||||||
await api.get(`shares/${id}`, {
|
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${id}_token`);
|
return (await api.get(`shares/${id}/metaData`)).data;
|
||||||
return (
|
|
||||||
await api.get(`shares/${id}/metaData`, {
|
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -53,26 +45,30 @@ const getMyShares = async (): Promise<MyShare[]> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getShareToken = async (id: string, password?: string) => {
|
const getShareToken = async (id: string, password?: string) => {
|
||||||
const { token } = (await api.post(`/shares/${id}/token`, { password })).data;
|
await api.post(`/shares/${id}/token`, { password });
|
||||||
|
|
||||||
sessionStorage.setItem(`share_${id}_token`, token);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isShareIdAvailable = async (id: string): Promise<boolean> => {
|
const isShareIdAvailable = async (id: string): Promise<boolean> => {
|
||||||
return (await api.get(`shares/isShareIdAvailable/${id}`)).data.isAvailable;
|
return (await api.get(`/shares/isShareIdAvailable/${id}`)).data.isAvailable;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFileDownloadUrl = async (shareId: string, fileId: string) => {
|
const doesFileSupportPreview = (fileName: string) => {
|
||||||
const shareToken = sessionStorage.getItem(`share_${shareId}_token`);
|
const mimeType = mime.contentType(fileName);
|
||||||
return (
|
|
||||||
await api.get(`shares/${shareId}/files/${fileId}/download`, {
|
if (!mimeType) return false;
|
||||||
headers: { "X-Share-Token": shareToken ?? "" },
|
|
||||||
})
|
const supportedMimeTypes = [
|
||||||
).data.url;
|
mimeType.startsWith("video/"),
|
||||||
|
mimeType.startsWith("image/"),
|
||||||
|
mimeType.startsWith("audio/"),
|
||||||
|
mimeType == "application/pdf",
|
||||||
|
];
|
||||||
|
|
||||||
|
return supportedMimeTypes.some((isSupported) => isSupported);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async (shareId: string, fileId: string) => {
|
const downloadFile = async (shareId: string, fileId: string) => {
|
||||||
window.location.href = await getFileDownloadUrl(shareId, fileId);
|
window.location.href = `${window.location.origin}/api/shares/${shareId}/files/${fileId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (
|
const uploadFile = async (
|
||||||
@@ -135,6 +131,7 @@ export default {
|
|||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
getMetaData,
|
getMetaData,
|
||||||
|
doesFileSupportPreview,
|
||||||
getMyShares,
|
getMyShares,
|
||||||
isShareIdAvailable,
|
isShareIdAvailable,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export type FileUpload = File & { uploadingProgress: number };
|
export type FileUpload = File & { uploadingProgress: number };
|
||||||
|
|
||||||
export type FileUploadResponse = { id: string; name: string };
|
export type FileUploadResponse = { id: string; name: string };
|
||||||
|
|
||||||
|
export type FileMetaData = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type Share = {
|
|||||||
creator: User;
|
creator: User;
|
||||||
description?: string;
|
description?: string;
|
||||||
expiration: Date;
|
expiration: Date;
|
||||||
|
hasPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateShare = {
|
export type CreateShare = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share",
|
||||||
"version": "0.8.0",
|
"version": "0.9.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",
|
||||||
|
|||||||
Reference in New Issue
Block a user