Skip to content
This repository was archived by the owner on Jun 29, 2025. It is now read-only.

Commit 008df06

Browse files
committed
feat: direct file link
1 parent cd9d828 commit 008df06

File tree

11 files changed

+144
-25
lines changed

11 files changed

+144
-25
lines changed

backend/src/file/file.controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import * as contentDisposition from "content-disposition";
1414
import { Response } from "express";
1515
import { CreateShareGuard } from "src/share/guard/createShare.guard";
1616
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
17-
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
1817
import { FileService } from "./file.service";
18+
import { FileSecurityGuard } from "./guard/fileSecurity.guard";
1919

2020
@Controller("shares/:shareId/files")
2121
export class FileController {
@@ -43,7 +43,7 @@ export class FileController {
4343
}
4444

4545
@Get("zip")
46-
@UseGuards(ShareSecurityGuard)
46+
@UseGuards(FileSecurityGuard)
4747
async getZip(
4848
@Res({ passthrough: true }) res: Response,
4949
@Param("shareId") shareId: string
@@ -58,7 +58,7 @@ export class FileController {
5858
}
5959

6060
@Get(":fileId")
61-
@UseGuards(ShareSecurityGuard)
61+
@UseGuards(FileSecurityGuard)
6262
async getFile(
6363
@Res({ passthrough: true }) res: Response,
6464
@Param("shareId") shareId: string,

backend/src/file/file.service.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,4 @@ export class FileService {
135135
getZip(shareId: string) {
136136
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`);
137137
}
138-
139-
140138
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
ExecutionContext,
3+
ForbiddenException,
4+
Injectable,
5+
NotFoundException,
6+
} from "@nestjs/common";
7+
import { Request } from "express";
8+
import * as moment from "moment";
9+
import { PrismaService } from "src/prisma/prisma.service";
10+
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
11+
import { ShareService } from "src/share/share.service";
12+
13+
@Injectable()
14+
export class FileSecurityGuard extends ShareSecurityGuard {
15+
constructor(
16+
private _shareService: ShareService,
17+
private _prisma: PrismaService
18+
) {
19+
super(_shareService, _prisma);
20+
}
21+
22+
async canActivate(context: ExecutionContext) {
23+
const request: Request = context.switchToHttp().getRequest();
24+
25+
const shareId = Object.prototype.hasOwnProperty.call(
26+
request.params,
27+
"shareId"
28+
)
29+
? request.params.shareId
30+
: request.params.id;
31+
32+
const shareToken = request.cookies[`share_${shareId}_token`];
33+
34+
const share = await this._prisma.share.findUnique({
35+
where: { id: shareId },
36+
include: { security: true },
37+
});
38+
39+
// If there is no share token the user requests a file directly
40+
if (!shareToken) {
41+
if (
42+
!share ||
43+
(moment().isAfter(share.expiration) &&
44+
!moment(share.expiration).isSame(0))
45+
) {
46+
throw new NotFoundException("File not found");
47+
}
48+
49+
if (share.security?.password)
50+
throw new ForbiddenException("This share is password protected");
51+
52+
if (share.security?.maxViews && share.security.maxViews <= share.views) {
53+
throw new ForbiddenException(
54+
"Maximum views exceeded",
55+
"share_max_views_exceeded"
56+
);
57+
}
58+
59+
await this._shareService.increaseViewCount(share);
60+
return true;
61+
} else {
62+
return super.canActivate(context);
63+
}
64+
}
65+
}

backend/src/reverseShare/dto/reverseShareTokenWithShare.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
1010
shareExpiration: Date;
1111

1212
@Expose()
13-
@Type(() => OmitType(MyShareDTO, ["recipients"] as const))
14-
share: Omit<MyShareDTO, "recipients" | "files" | "from" | "fromList">;
13+
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
14+
share: Omit<
15+
MyShareDTO,
16+
"recipients" | "files" | "from" | "fromList" | "hasPassword"
17+
>;
1518

1619
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
1720
return partial.map((part) =>

backend/src/share/dto/share.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export class ShareDTO {
2020
@Expose()
2121
description: string;
2222

23+
@Expose()
24+
hasPassword: boolean;
25+
2326
from(partial: Partial<ShareDTO>) {
2427
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });
2528
}

backend/src/share/guard/shareSecurity.guard.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ export class ShareSecurityGuard implements CanActivate {
3434
include: { security: true },
3535
});
3636

37-
const isExpired =
38-
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
39-
40-
if (!share || isExpired) throw new NotFoundException("Share not found");
37+
if (
38+
!share ||
39+
(moment().isAfter(share.expiration) &&
40+
!moment(share.expiration).isSame(0))
41+
)
42+
throw new NotFoundException("Share not found");
4143

4244
if (share.security?.password && !shareToken)
4345
throw new ForbiddenException(

backend/src/share/guard/shareTokenSecurity.guard.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ export class ShareTokenSecurity implements CanActivate {
2626
include: { security: true },
2727
});
2828

29-
const isExpired =
30-
moment().isAfter(share.expiration) && !moment(share.expiration).isSame(0);
31-
32-
if (!share || isExpired) throw new NotFoundException("Share not found");
29+
if (
30+
!share ||
31+
(moment().isAfter(share.expiration) &&
32+
!moment(share.expiration).isSame(0))
33+
)
34+
throw new NotFoundException("Share not found");
3335

3436
return true;
3537
}

backend/src/share/share.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,13 @@ export class ShareService {
204204
return sharesWithEmailRecipients;
205205
}
206206

207-
async get(id: string) {
207+
async get(id: string): Promise<any> {
208208
const share = await this.prisma.share.findUnique({
209209
where: { id },
210210
include: {
211211
files: true,
212212
creator: true,
213+
security: true,
213214
},
214215
});
215216

@@ -218,8 +219,10 @@ export class ShareService {
218219

219220
if (!share || !share.uploadLocked)
220221
throw new NotFoundException("Share not found");
221-
222-
return share as any;
222+
return {
223+
...share,
224+
hasPassword: share.security?.password ? true : false,
225+
};
223226
}
224227

225228
async getMetaData(id: string) {

frontend/src/components/share/FileList.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,57 @@
1-
import { ActionIcon, Group, Skeleton, Table } from "@mantine/core";
1+
import {
2+
ActionIcon,
3+
Group,
4+
Skeleton,
5+
Stack,
6+
Table,
7+
TextInput,
8+
} from "@mantine/core";
9+
import { useClipboard } from "@mantine/hooks";
10+
import { useModals } from "@mantine/modals";
211
import mime from "mime-types";
12+
313
import Link from "next/link";
4-
import { TbDownload, TbEye } from "react-icons/tb";
14+
import { TbDownload, TbEye, TbLink } from "react-icons/tb";
15+
import useConfig from "../../hooks/config.hook";
516
import shareService from "../../services/share.service";
617
import { FileMetaData } from "../../types/File.type";
18+
import { Share } from "../../types/share.type";
719
import { byteToHumanSizeString } from "../../utils/fileSize.util";
20+
import toast from "../../utils/toast.util";
821

922
const FileList = ({
1023
files,
11-
shareId,
24+
share,
1225
isLoading,
1326
}: {
1427
files?: FileMetaData[];
15-
shareId: string;
28+
share: Share;
1629
isLoading: boolean;
1730
}) => {
31+
const clipboard = useClipboard();
32+
const config = useConfig();
33+
const modals = useModals();
34+
35+
const copyFileLink = (file: FileMetaData) => {
36+
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${
37+
file.id
38+
}`;
39+
40+
if (window.isSecureContext) {
41+
clipboard.copy(link);
42+
toast.success("Your file link was copied to the keyboard.");
43+
} else {
44+
modals.openModal({
45+
title: "File link",
46+
children: (
47+
<Stack align="stretch">
48+
<TextInput variant="filled" value={link} />
49+
</Stack>
50+
),
51+
});
52+
}
53+
};
54+
1855
return (
1956
<Table>
2057
<thead>
@@ -36,7 +73,7 @@ const FileList = ({
3673
{shareService.doesFileSupportPreview(file.name) && (
3774
<ActionIcon
3875
component={Link}
39-
href={`/share/${shareId}/preview/${
76+
href={`/share/${share.id}/preview/${
4077
file.id
4178
}?type=${mime.contentType(file.name)}`}
4279
target="_blank"
@@ -45,10 +82,15 @@ const FileList = ({
4582
<TbEye />
4683
</ActionIcon>
4784
)}
85+
{!share.hasPassword && (
86+
<ActionIcon size={25} onClick={() => copyFileLink(file)}>
87+
<TbLink />
88+
</ActionIcon>
89+
)}
4890
<ActionIcon
4991
size={25}
5092
onClick={async () => {
51-
await shareService.downloadFile(shareId, file.id);
93+
await shareService.downloadFile(share.id, file.id);
5294
}}
5395
>
5496
<TbDownload />

frontend/src/pages/share/[shareId]/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const Share = ({ shareId }: { shareId: string }) => {
8585
{share?.files.length > 1 && <DownloadAllButton shareId={shareId} />}
8686
</Group>
8787

88-
<FileList files={share?.files} shareId={shareId} isLoading={!share} />
88+
<FileList files={share?.files} share={share!} isLoading={!share} />
8989
</>
9090
);
9191
};

0 commit comments

Comments
 (0)