From 4af8e43bb27bd6bc979052ae657e4f8fa06d55ad Mon Sep 17 00:00:00 2001 From: Romaric Mourgues Date: Tue, 16 May 2023 16:39:35 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20Implement=20password=20and=20exp?= =?UTF-8?q?iration=20date=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implemented password and expiration date on conf and backend side * Implemented password modal on shared side * Add public link related tests * Fix tests --- .../documents/database-models.md | 2 + .../services/documents/entities/drive-file.ts | 2 + .../src/services/documents/services/index.ts | 1 + .../node/src/services/documents/utils.ts | 14 +- .../src/services/documents/web/schemas.ts | 2 + .../test/e2e/documents/public-links.spec.ts | 232 ++++++++++++++++++ .../frontend/src/app/features/drive/types.ts | 2 + .../update-access/public-link-access.tsx | 175 ++++++++++--- .../app/views/client/body/drive/shared.tsx | 67 ++++- 9 files changed, 464 insertions(+), 33 deletions(-) create mode 100644 tdrive/backend/node/test/e2e/documents/public-links.spec.ts diff --git a/Documentation/docs/internal-documentation/backend-services/documents/database-models.md b/Documentation/docs/internal-documentation/backend-services/documents/database-models.md index 23537040..14fa5e5f 100644 --- a/Documentation/docs/internal-documentation/backend-services/documents/database-models.md +++ b/Documentation/docs/internal-documentation/backend-services/documents/database-models.md @@ -30,6 +30,8 @@ description: Documents database models type AccessInformation = { public: { token: string; + password: string; + expiration: number; level: publicAccessLevel; }; entities: AuthEntity[]; diff --git a/tdrive/backend/node/src/services/documents/entities/drive-file.ts b/tdrive/backend/node/src/services/documents/entities/drive-file.ts index 10d50c4f..fda31453 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.ts @@ -78,6 +78,8 @@ export class DriveFile { export type AccessInformation = { public?: { token: string; + password: string; + expiration: number; level: publicAccessLevel; }; entities: AuthEntity[]; diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index 39a07910..1cd993b4 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -386,6 +386,7 @@ export class DocumentsService { return item; } catch (error) { + console.error(error); this.logger.error("Failed to update drive item", error); throw new CrudException("Failed to update item", 500); } diff --git a/tdrive/backend/node/src/services/documents/utils.ts b/tdrive/backend/node/src/services/documents/utils.ts index 221f7d4f..c126d27e 100644 --- a/tdrive/backend/node/src/services/documents/utils.ts +++ b/tdrive/backend/node/src/services/documents/utils.ts @@ -68,6 +68,8 @@ export const getDefaultDriveItem = ( ], public: { level: "none", + password: "", + expiration: 0, token: generateAccessToken(), }, }, @@ -357,7 +359,7 @@ export const getAccessLevel = async ( ? "manage" : "write"; - const publicToken = context.public_token; + let publicToken = context.public_token; try { item = @@ -379,7 +381,15 @@ export const getAccessLevel = async ( //Public access if (publicToken) { if (!item.access_info.public.token) return "none"; - const { token: itemToken, level: itemLevel } = item.access_info.public; + const { token: itemToken, level: itemLevel, password, expiration } = item.access_info.public; + if (expiration && expiration < Date.now()) return "none"; + if (password) { + const data = publicToken.split("+"); + if (data.length !== 2) return "none"; + const [extractedPublicToken, publicTokenPassword] = data; + if (publicTokenPassword !== password) return "none"; + publicToken = extractedPublicToken; + } if (itemToken === publicToken) return itemLevel; } diff --git a/tdrive/backend/node/src/services/documents/web/schemas.ts b/tdrive/backend/node/src/services/documents/web/schemas.ts index f49aa3d5..e29653b9 100644 --- a/tdrive/backend/node/src/services/documents/web/schemas.ts +++ b/tdrive/backend/node/src/services/documents/web/schemas.ts @@ -60,6 +60,8 @@ const documentSchema = { type: "object", properties: { token: { type: "string" }, + password: { type: "string" }, + expiration: { type: "number" }, level: { type: "string" }, }, }, diff --git a/tdrive/backend/node/test/e2e/documents/public-links.spec.ts b/tdrive/backend/node/test/e2e/documents/public-links.spec.ts new file mode 100644 index 00000000..cf408150 --- /dev/null +++ b/tdrive/backend/node/test/e2e/documents/public-links.spec.ts @@ -0,0 +1,232 @@ +import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; +import { deserialize } from "class-transformer"; +import { AccessInformation, DriveFile } from "../../../src/services/documents/entities/drive-file"; +import { FileVersion } from "../../../src/services/documents/entities/file-version"; +import { DriveFileAccessLevel, DriveItemDetails } from "../../../src/services/documents/types"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { e2e_createDocument, e2e_updateDocument } from "./utils"; + +const url = "/internal/services/documents/v1"; + +describe("the public links feature", () => { + let platform: TestPlatform; + + class DriveFileMockClass { + id: string; + name: string; + size: number; + added: string; + parent_id: string; + company_id: string; + access_info: AccessInformation; + } + + class FullDriveInfoMockClass { + path: DriveFile[]; + item?: DriveFile; + versions?: FileVersion[]; + children: DriveFile[]; + access: DriveFileAccessLevel | "none"; + } + + beforeAll(async () => { + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "search", + "files", + "websocket", + "messages", + "auth", + "realtime", + "channels", + "counter", + "statistics", + "platform-services", + "documents", + ], + }); + }); + + afterAll(async () => { + await platform.tearDown(); + await platform.app.close(); + }); + + const createItem = async (): Promise => { + await TestDbService.getInstance(platform, true); + + const item = { + name: "public file", + parent_id: "root", + company_id: platform.workspace.company_id, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + return deserialize(DriveFileMockClass, response.body); + }; + + let publicFile: DriveFileMockClass; + + it("did create the drive item", async done => { + const result = await createItem(); + publicFile = result; + + expect(result).toBeDefined(); + expect(result.name).toEqual("public file"); + expect(result.added).toBeDefined(); + expect(result.access_info).toBeDefined(); + + done?.(); + }); + + it("unable to access non public file", async done => { + const res = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${publicFile.access_info.public?.token}`, + headers: {}, + }); + + expect(res.statusCode).toBe(401); + + done?.(); + }); + + it("should access public file", async done => { + const res = await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + }, + }, + }); + expect(res.statusCode).toBe(200); + const file = deserialize(DriveFileMockClass, res.body); + expect(file.access_info.public?.level).toBe("read"); + + const resPublicRaw = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${publicFile.access_info.public?.token}`, + headers: {}, + }); + const resPublic = deserialize(FullDriveInfoMockClass, resPublicRaw.body); + expect(resPublicRaw.statusCode).toBe(200); + expect(resPublic.item?.id).toBe(publicFile.id); + + done?.(); + }); + + it("unable to access expired public file link", async done => { + await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + expiration: Date.now() + 1000 * 60, //In the future + }, + }, + }); + + let resPublicRaw = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${publicFile.access_info.public?.token}`, + headers: {}, + }); + const resPublic = deserialize(FullDriveInfoMockClass, resPublicRaw.body); + expect(resPublicRaw.statusCode).toBe(200); + expect(resPublic.item?.id).toBe(publicFile.id); + + await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + expiration: 123, //In the past + }, + }, + }); + + resPublicRaw = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${publicFile.access_info.public?.token}`, + headers: {}, + }); + expect(resPublicRaw.statusCode).toBe(401); + + await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + expiration: 0, //Reset to default + }, + }, + }); + + done?.(); + }); + + it("access public file link with password", async done => { + await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + password: "abcdef", + }, + }, + }); + + let resPublicRaw = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${ + publicFile.access_info.public?.token + }%2B${"abcdef"}`, + headers: {}, + }); + let resPublic = deserialize(FullDriveInfoMockClass, resPublicRaw.body); + expect(resPublicRaw.statusCode).toBe(200); + expect(resPublic.item?.id).toBe(publicFile.id); + + resPublicRaw = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${publicFile.company_id}/item/${publicFile.id}?public_token=${publicFile.access_info.public?.token}`, + headers: {}, + }); + expect(resPublicRaw.statusCode).toBe(401); + + await e2e_updateDocument(platform, publicFile.id, { + ...publicFile, + access_info: { + ...publicFile.access_info, + public: { + ...publicFile.access_info.public!, + level: "read", + password: "", + }, + }, + }); + + done?.(); + }); +}); diff --git a/tdrive/frontend/src/app/features/drive/types.ts b/tdrive/frontend/src/app/features/drive/types.ts index 1136fd5b..2cf148ce 100644 --- a/tdrive/frontend/src/app/features/drive/types.ts +++ b/tdrive/frontend/src/app/features/drive/types.ts @@ -37,6 +37,8 @@ export type DriveFileAccessLevel = 'none' | 'read' | 'write' | 'manage'; export type DriveItemAccessInfo = { public?: { token: string; + expiration?: number; + password?: string; level: DriveFileAccessLevel; }; entities: AuthEntity[]; diff --git a/tdrive/frontend/src/app/views/client/body/drive/modals/update-access/public-link-access.tsx b/tdrive/frontend/src/app/views/client/body/drive/modals/update-access/public-link-access.tsx index 546a2090..ad2903ed 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/modals/update-access/public-link-access.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/modals/update-access/public-link-access.tsx @@ -1,9 +1,13 @@ import A from '@atoms/link'; -import { Base, Info } from '@atoms/text'; +import { Base, Info, Subtitle } from '@atoms/text'; import { useDriveItem, getPublicLink } from '@features/drive/hooks/use-drive-item'; import { ToasterService } from '@features/global/services/toaster-service'; import { copyToClipboard } from '@features/global/utils/CopyClipboard'; +import { Checkbox } from 'app/atoms/input/input-checkbox'; +import { Input } from 'app/atoms/input/input-text'; +import { useEffect, useState } from 'react'; import { AccessLevel } from './common'; +import moment from 'moment'; export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boolean }) => { const { item, loading, update } = useDriveItem(id); @@ -11,44 +15,155 @@ export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boo return ( <> Public link access -
-
- {item?.access_info?.public?.level !== 'none' && ( - Anyone with this link will have access to this item. - )} - {item?.access_info?.public?.level === 'none' && ( - This item is not available by public link. - )} - {item?.access_info?.public?.level !== 'none' && ( - <> -
- { - copyToClipboard(publicLink); - ToasterService.success('Public link copied to clipboard'); - }} - > - Copy public link to clip board - - - )} +
+
+
+ {item?.access_info?.public?.level !== 'none' && ( + Anyone with this link will have access to this item. + )} + {item?.access_info?.public?.level === 'none' && ( + This item is not available by public link. + )} + {item?.access_info?.public?.level !== 'none' && ( + <> +
+ { + copyToClipboard(publicLink); + ToasterService.success('Public link copied to clipboard'); + }} + > + Copy public link to clip board + + + )} +
+
+ { + update({ + access_info: { + entities: item?.access_info.entities || [], + public: { ...(item?.access_info?.public || { token: '' }), level }, + }, + }); + }} + /> +
-
- { + password={item?.access_info?.public?.password} + expiration={item?.access_info?.public?.expiration} + onChangePassword={(password: string) => { update({ access_info: { entities: item?.access_info.entities || [], - public: { ...(item?.access_info?.public || { token: '' }), level }, + public: { + ...item!.access_info!.public!, + password: password || '', + }, + }, + }); + }} + onChangeExpiration={(expiration: number) => { + update({ + access_info: { + entities: item?.access_info.entities || [], + public: { + ...item!.access_info!.public!, + expiration: expiration || 0, + }, }, }); }} /> -
+ )} +
{' '} + + ); +}; + +const PublicLinkOptions = (props: { + disabled?: boolean; + password?: string; + expiration?: number; + onChangePassword: Function; + onChangeExpiration: Function; +}) => { + const [usePassword, setUsePassword] = useState(!!props.password); + const [password, setPassword] = useState(props.password); + const [useExpiration, setUseExpiration] = useState((props.expiration || 0) > 0); + const [expiration, setExpiration] = useState(props.expiration); + + useEffect(() => { + props.onChangePassword(usePassword ? password : ''); + }, [usePassword, password]); + + useEffect(() => { + props.onChangeExpiration(useExpiration ? expiration : 0); + }, [useExpiration, expiration]); + + return ( + <> + Public link security +
+ { + setUsePassword(s); + if (!password && s) setPassword(Math.random().toString(36).slice(-8)); + }} + value={!!usePassword} + label="Password" + /> +
+ {!!usePassword && ( + setPassword(e.target.value)} + onClick={() => { + if (password) copyToClipboard(password); + ToasterService.success('Password copied to clipboard'); + }} + /> + )} +
+
+ { + setUseExpiration(s); + if (!expiration && s) setExpiration(Date.now() + 1000 * 60 * 60 * 24 * 7); + }} + value={!!useExpiration} + label="Expiration" + /> + {useExpiration && (expiration || 0) < Date.now() && ( + (Expired) + )} + {useExpiration && (expiration || 0) > Date.now() && ( + ({moment(expiration).fromNow(true)}) + )}{' '} +
+ {!!useExpiration && ( + { + e.target.value && setExpiration(new Date(e.target.value).getTime()); + }} + /> + )}
); diff --git a/tdrive/frontend/src/app/views/client/body/drive/shared.tsx b/tdrive/frontend/src/app/views/client/body/drive/shared.tsx index 43a7232d..2ce6ec99 100755 --- a/tdrive/frontend/src/app/views/client/body/drive/shared.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/shared.tsx @@ -11,6 +11,10 @@ import shortUUID from 'short-uuid'; import Avatar from '../../../../atoms/avatar'; import { setPublicLinkToken } from '../../../../features/drive/api-client/api-client'; import useRouterCompany from '../../../../features/router/hooks/use-router-company'; +import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; +import { Base, Subtitle, Title } from 'app/atoms/text'; +import { Input } from 'app/atoms/input/input-text'; +import { Button } from 'app/atoms/button/button'; export default () => { const companyId = useRouterCompany(); @@ -62,9 +66,70 @@ export default () => {
- + + +
); }; + +const AccessChecker = ({ + children, + folderId, + token, +}: { + children: React.ReactNode; + token?: string; + folderId: string; +}) => { + const { details, loading, refresh } = useDriveItem(folderId); + const [password, setPassword] = useState((token || '').split('+')[1] || ''); + + useEffect(() => { + refresh(folderId); + }, []); + + if (!details?.item?.id && loading) { + return <>; + } + + if (!details?.item?.id && !loading) { + return ( +
+
+
+ You don't have access to this document or folder. +
+ The public link you are using may be invalid or expired. +
+
+ I have a password +
+
+ setPassword(e.target.value)} + /> + +
+
+
+ ); + } + + return <>{children}; +};