mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-02-19 01:55:48 -05:00
feat: direct file link
This commit is contained in:
parent
cd9d828686
commit
008df06b5c
11 changed files with 144 additions and 25 deletions
|
@ -14,8 +14,8 @@ import * as contentDisposition from "content-disposition";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
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 {
|
||||||
|
@ -43,7 +43,7 @@ export class FileController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("zip")
|
@Get("zip")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getZip(
|
async getZip(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string
|
@Param("shareId") shareId: string
|
||||||
|
@ -58,7 +58,7 @@ export class FileController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":fileId")
|
@Get(":fileId")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(FileSecurityGuard)
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param("shareId") shareId: string,
|
@Param("shareId") shareId: string,
|
||||||
|
|
|
@ -135,6 +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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
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 });
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,10 +34,12 @@ export class ShareSecurityGuard implements CanActivate {
|
||||||
include: { security: true },
|
include: { security: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isExpired =
|
if (
|
||||||
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
|
!share ||
|
||||||
|
(moment().isAfter(share.expiration) &&
|
||||||
if (!share || isExpired) throw new NotFoundException("Share not found");
|
!moment(share.expiration).isSame(0))
|
||||||
|
)
|
||||||
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
if (share.security?.password && !shareToken)
|
if (share.security?.password && !shareToken)
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
|
|
|
@ -26,10 +26,12 @@ export class ShareTokenSecurity implements CanActivate {
|
||||||
include: { security: true },
|
include: { security: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isExpired =
|
if (
|
||||||
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
|
!share ||
|
||||||
|
(moment().isAfter(share.expiration) &&
|
||||||
if (!share || isExpired) throw new NotFoundException("Share not found");
|
!moment(share.expiration).isSame(0))
|
||||||
|
)
|
||||||
|
throw new NotFoundException("Share not found");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -1,20 +1,57 @@
|
||||||
import { ActionIcon, Group, Skeleton, Table } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useClipboard } from "@mantine/hooks";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TbDownload, TbEye } from "react-icons/tb";
|
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import shareService from "../../services/share.service";
|
import shareService from "../../services/share.service";
|
||||||
import { FileMetaData } from "../../types/File.type";
|
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?: FileMetaData[];
|
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>
|
||||||
|
@ -36,7 +73,7 @@ const FileList = ({
|
||||||
{shareService.doesFileSupportPreview(file.name) && (
|
{shareService.doesFileSupportPreview(file.name) && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component={Link}
|
component={Link}
|
||||||
href={`/share/${shareId}/preview/${
|
href={`/share/${share.id}/preview/${
|
||||||
file.id
|
file.id
|
||||||
}?type=${mime.contentType(file.name)}`}
|
}?type=${mime.contentType(file.name)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
@ -45,10 +82,15 @@ const FileList = ({
|
||||||
<TbEye />
|
<TbEye />
|
||||||
</ActionIcon>
|
</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 />
|
||||||
|
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
Loading…
Add table
Reference in a new issue