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 { CreateShareGuard } from "src/share/guard/createShare.guard";
|
||||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
|
@ -43,7 +43,7 @@ export class FileController {
|
|||
}
|
||||
|
||||
@Get("zip")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getZip(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string
|
||||
|
@ -58,7 +58,7 @@ export class FileController {
|
|||
}
|
||||
|
||||
@Get(":fileId")
|
||||
@UseGuards(ShareSecurityGuard)
|
||||
@UseGuards(FileSecurityGuard)
|
||||
async getFile(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param("shareId") shareId: string,
|
||||
|
|
|
@ -135,6 +135,4 @@ export class FileService {
|
|||
getZip(shareId: string) {
|
||||
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;
|
||||
|
||||
@Expose()
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients"] as const))
|
||||
share: Omit<MyShareDTO, "recipients" | "files" | "from" | "fromList">;
|
||||
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
|
||||
share: Omit<
|
||||
MyShareDTO,
|
||||
"recipients" | "files" | "from" | "fromList" | "hasPassword"
|
||||
>;
|
||||
|
||||
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
|
||||
return partial.map((part) =>
|
||||
|
|
|
@ -20,6 +20,9 @@ export class ShareDTO {
|
|||
@Expose()
|
||||
description: string;
|
||||
|
||||
@Expose()
|
||||
hasPassword: boolean;
|
||||
|
||||
from(partial: Partial<ShareDTO>) {
|
||||
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
|
|
@ -34,10 +34,12 @@ export class ShareSecurityGuard implements CanActivate {
|
|||
include: { security: true },
|
||||
});
|
||||
|
||||
const isExpired =
|
||||
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
|
||||
|
||||
if (!share || isExpired) throw new NotFoundException("Share not found");
|
||||
if (
|
||||
!share ||
|
||||
(moment().isAfter(share.expiration) &&
|
||||
!moment(share.expiration).isSame(0))
|
||||
)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
if (share.security?.password && !shareToken)
|
||||
throw new ForbiddenException(
|
||||
|
|
|
@ -26,10 +26,12 @@ export class ShareTokenSecurity implements CanActivate {
|
|||
include: { security: true },
|
||||
});
|
||||
|
||||
const isExpired =
|
||||
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
|
||||
|
||||
if (!share || isExpired) throw new NotFoundException("Share not found");
|
||||
if (
|
||||
!share ||
|
||||
(moment().isAfter(share.expiration) &&
|
||||
!moment(share.expiration).isSame(0))
|
||||
)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -204,12 +204,13 @@ export class ShareService {
|
|||
return sharesWithEmailRecipients;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
async get(id: string): Promise<any> {
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: true,
|
||||
creator: true,
|
||||
security: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -218,8 +219,10 @@ export class ShareService {
|
|||
|
||||
if (!share || !share.uploadLocked)
|
||||
throw new NotFoundException("Share not found");
|
||||
|
||||
return share as any;
|
||||
return {
|
||||
...share,
|
||||
hasPassword: share.security?.password ? true : false,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 { FileMetaData } from "../../types/File.type";
|
||||
import { Share } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
shareId,
|
||||
share,
|
||||
isLoading,
|
||||
}: {
|
||||
files?: FileMetaData[];
|
||||
shareId: string;
|
||||
share: Share;
|
||||
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 (
|
||||
<Table>
|
||||
<thead>
|
||||
|
@ -36,7 +73,7 @@ const FileList = ({
|
|||
{shareService.doesFileSupportPreview(file.name) && (
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/share/${shareId}/preview/${
|
||||
href={`/share/${share.id}/preview/${
|
||||
file.id
|
||||
}?type=${mime.contentType(file.name)}`}
|
||||
target="_blank"
|
||||
|
@ -45,10 +82,15 @@ const FileList = ({
|
|||
<TbEye />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!share.hasPassword && (
|
||||
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={async () => {
|
||||
await shareService.downloadFile(shareId, file.id);
|
||||
await shareService.downloadFile(share.id, file.id);
|
||||
}}
|
||||
>
|
||||
<TbDownload />
|
||||
|
|
|
@ -85,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => {
|
|||
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
|
||||
</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;
|
||||
description?: string;
|
||||
expiration: Date;
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
export type CreateShare = {
|
||||
|
|
Loading…
Add table
Reference in a new issue