Skip to content

Commit

Permalink
feat: direct file link
Browse files Browse the repository at this point in the history
  • Loading branch information
stonith404 committed Jan 31, 2023
1 parent cd9d828 commit 008df06
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 25 deletions.
6 changes: 3 additions & 3 deletions backend/src/file/file.controller.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,7 +43,7 @@ export class FileController {
}

@Get("zip")
@UseGuards(ShareSecurityGuard)
@UseGuards(FileSecurityGuard)
async getZip(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string
Expand All @@ -58,7 +58,7 @@ export class FileController {
}

@Get(":fileId")
@UseGuards(ShareSecurityGuard)
@UseGuards(FileSecurityGuard)
async getFile(
@Res({ passthrough: true }) res: Response,
@Param("shareId") shareId: string,
Expand Down
2 changes: 0 additions & 2 deletions backend/src/file/file.service.ts
Expand Up @@ -135,6 +135,4 @@ export class FileService {
getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
}


}
65 changes: 65 additions & 0 deletions backend/src/file/guard/fileSecurity.guard.ts
@@ -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);
}
}
}
7 changes: 5 additions & 2 deletions backend/src/reverseShare/dto/reverseShareTokenWithShare.ts
Expand Up @@ -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) =>
Expand Down
3 changes: 3 additions & 0 deletions backend/src/share/dto/share.dto.ts
Expand Up @@ -20,6 +20,9 @@ export class ShareDTO {
@Expose()
description: string;

@Expose()
hasPassword: boolean;

from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
}
Expand Down
10 changes: 6 additions & 4 deletions backend/src/share/guard/shareSecurity.guard.ts
Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions backend/src/share/guard/shareTokenSecurity.guard.ts
Expand Up @@ -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;
}
Expand Down
9 changes: 6 additions & 3 deletions backend/src/share/share.service.ts
Expand Up @@ -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,
},
});

Expand All @@ -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) {
Expand Down
54 changes: 48 additions & 6 deletions frontend/src/components/share/FileList.tsx
@@ -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>
Expand All @@ -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"
Expand All @@ -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 />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/share/[shareId]/index.tsx
Expand Up @@ -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} />
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/share.type.ts
Expand Up @@ -6,6 +6,7 @@ export type Share = {
creator: User;
description?: string;
expiration: Date;
hasPassword: boolean;
};

export type CreateShare = {
Expand Down

0 comments on commit 008df06

Please sign in to comment.