Compare commits

..

6 Commits

Author SHA1 Message Date
Elias Schneider
3d5c919110 release: 0.9.0 2023-01-31 15:25:01 +01:00
Elias Schneider
008df06b5c feat: direct file link 2023-01-31 15:22:08 +01:00
Elias Schneider
cd9d828686 refactor: move guard checks to service 2023-01-31 13:53:23 +01:00
Elias Schneider
233c26e5cf fix: improve send test email UX 2023-01-31 13:16:11 +01:00
Elias Schneider
91a6b3f716 feat: file preview 2023-01-31 09:03:03 +01:00
Elias Schneider
0a2b7b1243 refactor: use cookie instead of local storage for share token 2023-01-26 21:18:22 +01:00
28 changed files with 467 additions and 211 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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