1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-06-30 06:30:11 +02:00

feat: direct file link

This commit is contained in:
Elias Schneider 2023-01-31 15:22:08 +01:00
parent cd9d828686
commit 008df06b5c
No known key found for this signature in database
GPG Key ID: 07E623B294202B6C
11 changed files with 144 additions and 25 deletions

View File

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

View File

@ -135,6 +135,4 @@ export class FileService {
getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
}
}

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

View File

@ -20,6 +20,9 @@ export class ShareDTO {
@Expose()
description: string;
@Expose()
hasPassword: boolean;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export type Share = {
creator: User;
description?: string;
expiration: Date;
hasPassword: boolean;
};
export type CreateShare = {