From 5e655996c752ff501471f1ced8b4d8cb85825827 Mon Sep 17 00:00:00 2001 From: Romaric Mourgues Date: Tue, 30 May 2023 11:15:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=EF=B8=8F=20Implement=20personal=20dri?= =?UTF-8?q?ve=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement personal drive * Fix tests * Add my-drive tests * Fixing tests * Fixing tests * Fix tests --- README.md | 6 +- .../documents/entities/drive-file.search.ts | 17 + .../documents/services/access-check.ts | 327 ++++++++++++++ .../src/services/documents/services/index.ts | 39 +- .../node/src/services/documents/utils.ts | 401 ++---------------- .../node/test/e2e/documents/documents.spec.ts | 8 +- .../node/test/e2e/documents/my-drive.spec.ts | 137 ++++++ .../views/client/body/drive/header-path.tsx | 24 +- .../src/app/views/client/side-bar/index.tsx | 20 +- 9 files changed, 591 insertions(+), 388 deletions(-) create mode 100644 tdrive/backend/node/src/services/documents/services/access-check.ts create mode 100644 tdrive/backend/node/test/e2e/documents/my-drive.spec.ts diff --git a/README.md b/README.md index f4de0094..190bc7b7 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ ## Run it in development mode 1. Launch mongo using `docker run -p 27017:27017 -d mongo` -2. Launch frontend with `cd twake/frontend/; HTTPS=true yarn dev:start` -3. Launch backend with `cd twake/backend/node/; SEARCH_DRIVER=mongodb DB_DRIVER=mongodb PUBSUB_TYPE=local DB_MONGO_URI=mongodb://localhost:27017 STORAGE_LOCAL_PATH=/[full-path-to-store-documents]/documents NODE_ENV=development yarn dev` -4. If you need more parameters, create/edit `twake/backend/node/config/development.json` file +2. Launch frontend with `cd tdrive/frontend/; yarn dev:start` +3. Launch backend with `cd tdrive/backend/node/; SEARCH_DRIVER=mongodb DB_DRIVER=mongodb PUBSUB_TYPE=local DB_MONGO_URI=mongodb://localhost:27017 STORAGE_LOCAL_PATH=/[full-path-to-store-documents]/documents NODE_ENV=development yarn dev` +4. If you need more parameters, create/edit `tdrive/backend/node/config/development.json` file App will be running on port 3000. diff --git a/tdrive/backend/node/src/services/documents/entities/drive-file.search.ts b/tdrive/backend/node/src/services/documents/entities/drive-file.search.ts index d9db47b1..722b4a42 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.search.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.search.ts @@ -9,6 +9,23 @@ export default { added: entity.added, name: entity.name, company_id: entity.company_id, + access_users: ["user_1234", "user_454"], + access_users_x_initiator: ["user_1234_x_user_4343", "user_1234_x_user_4343"], + + access_entities: [ + { + type: "user", + level: "read", + target: "user_1234", + source: "user_5678", + }, + { + type: "user", + level: "read", + target: "user_abcd", + source: "user_5678", + }, + ], }), mongoMapping: { text: { diff --git a/tdrive/backend/node/src/services/documents/services/access-check.ts b/tdrive/backend/node/src/services/documents/services/access-check.ts new file mode 100644 index 00000000..1b23d449 --- /dev/null +++ b/tdrive/backend/node/src/services/documents/services/access-check.ts @@ -0,0 +1,327 @@ +import crypto from "crypto"; +import _ from "lodash"; +import { logger } from "../../../core/platform/framework"; +import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; +import globalResolver from "../../global-resolver"; +import { DriveFile } from "../entities/drive-file"; +import { CompanyExecutionContext, DriveFileAccessLevel } from "../types"; + +/** + * Generates a random sha1 access token + * + * @returns {String} - the random access token ( sha1 hex digest ). + */ +export const generateAccessToken = (): string => { + const randomBytes = crypto.randomBytes(64); + + return crypto.createHash("sha1").update(randomBytes).digest("hex"); +}; + +/** + * Checks if the level meets the required level. + * + * @param {publicAccessLevel | DriveFileAccessLevel} requiredLevel + * @param {publicAccessLevel} level + * @returns {boolean} + */ +export const hasAccessLevel = ( + requiredLevel: DriveFileAccessLevel | "none", + level: DriveFileAccessLevel | "none", +): boolean => { + if (requiredLevel === level) return true; + + if (requiredLevel === "write") { + return level === "manage"; + } + + if (requiredLevel === "read") { + return level === "manage" || level === "write"; + } + + return requiredLevel === "none"; +}; + +/** + * checks the current user is a guest + * + * @param {CompanyExecutionContext} context + * @returns {Promise} + */ +export const isCompanyGuest = async (context: CompanyExecutionContext): Promise => { + if (await isCompanyApplication(context)) { + return false; + } + + const userRole = await globalResolver.services.companies.getUserRole( + context.company.id, + context.user?.id, + ); + + return userRole === "guest" || !userRole; +}; + +/** + * checks the current user is a admin + * + * @param {CompanyExecutionContext} context + * @returns {Promise} + */ +export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise => { + if (await isCompanyApplication(context)) { + return true; + } + + const userRole = await globalResolver.services.companies.getUserRole( + context.company.id, + context.user?.id, + ); + + return userRole === "admin"; +}; + +/** + * checks the current user is a admin + * + * @param {CompanyExecutionContext} context + * @returns {Promise} + */ +export const isCompanyApplication = async (context: CompanyExecutionContext): Promise => { + if (context.user?.application_id) { + //Applications do everything (if they are added to the company) + if ( + !!( + await globalResolver.services.applications.companyApps.get({ + company_id: context.company.id, + application_id: context.user?.application_id, + }) + )?.application?.id + ) { + return true; + } + } + return false; +}; + +/** + * checks if access can be granted for the drive item + * + * @param {string} id + * @param {DriveFile | null} item + * @param {DriveFileAccessLevel} level + * @param {Repository} repository + * @param {CompanyExecutionContext} context + * @param {string} token + * @returns {Promise} + */ +export const checkAccess = async ( + id: string, + item: DriveFile | null, + level: DriveFileAccessLevel, + repository: Repository, + context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string }, +): Promise => { + if (context.user?.server_request) { + return true; + } + + const grantedLevel = await getAccessLevel(id, item, repository, context); + const hasAccess = hasAccessLevel(level, grantedLevel); + logger.info( + `Got level ${grantedLevel} for drive item ${id} and required ${level} - returning ${hasAccess}`, + ); + return hasAccess; +}; + +/** + * get maximum level for the drive item + * + * @param {string} id + * @param {DriveFile | null} item + * @param {Repository} repository + * @param {CompanyExecutionContext} context + * @param {string} token + * @returns {Promise} + */ +export const getAccessLevel = async ( + id: string, + item: DriveFile | null, + repository: Repository, + context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string }, +): Promise => { + if (!id || id === "root") + return !context?.user?.id ? "none" : (await isCompanyGuest(context)) ? "read" : "manage"; + if (id === "trash") + return (await isCompanyGuest(context)) || !context?.user?.id + ? "none" + : (await isCompanyAdmin(context)) + ? "manage" + : "write"; + + //If it is my personal folder, I have full access + if (context?.user?.id && id.startsWith("user_")) { + if (id === "user_" + context.user?.id) return "manage"; + if (await isCompanyApplication(context)) return "manage"; + } + + let publicToken = context.public_token; + + try { + item = + item || + (await repository.findOne({ + id, + company_id: context.company.id, + })); + + if (!item) { + throw Error("Drive item doesn't exist"); + } + + if (await isCompanyApplication(context)) { + return "manage"; + } + + /* + * Specific user or channel rule is applied first. Then less restrictive level will be chosen + * between the parent folder and company accesses. + */ + + //Public access + if (publicToken) { + if (!item.access_info.public.token) return "none"; + 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; + } + + const accessEntities = item.access_info.entities || []; + const otherLevels = []; + + //From there a user must be logged in + if (context?.user?.id) { + //Users + const matchingUser = accessEntities.find(a => a.type === "user" && a.id === context.user?.id); + if (matchingUser) return matchingUser.level; + + //Channels + if (context.tdrive_tab_token) { + try { + const [channelId] = context.tdrive_tab_token.split("+"); //First item will be the channel id + const matchingChannel = accessEntities.find( + a => a.type === "channel" && a.id === channelId, + ); + if (matchingChannel) return matchingChannel.level; + } catch (e) { + console.log(e); + } + } + + //Companies + const matchingCompany = accessEntities.find( + a => a.type === "company" && a.id === context.company.id, + ); + if (matchingCompany) otherLevels.push(matchingCompany.level); + } + + //Parent folder + const maxParentFolderLevel = + accessEntities.find(a => a.type === "folder" && a.id === "parent")?.level || "none"; + if (maxParentFolderLevel === "none") { + otherLevels.push(maxParentFolderLevel); + } else { + const parentFolderLevel = await getAccessLevel(item.parent_id, null, repository, context); + otherLevels.push(parentFolderLevel); + } + + //Return least restrictive level of otherLevels + return otherLevels.reduce( + (previousValue, b) => + hasAccessLevel(b as DriveFileAccessLevel, previousValue as DriveFileAccessLevel) + ? previousValue + : b, + "none", + ) as DriveFileAccessLevel | "none"; + } catch (error) { + throw Error(error); + } +}; + +/** + * Isolate access level information from parent folder logic + * Used when putting folder in the trash + * @param id + * @param item + * @param repository + */ +export const makeStandaloneAccessLevel = async ( + companyId: string, + id: string, + item: DriveFile | null, + repository: Repository, + options: { removePublicAccess?: boolean } = { removePublicAccess: true }, +): Promise => { + item = + item || + (await repository.findOne({ + id, + company_id: companyId, + })); + + if (!item) { + throw Error("Drive item doesn't exist"); + } + + const accessInfo = _.cloneDeep(item.access_info); + + if (options?.removePublicAccess && accessInfo?.public?.level) accessInfo.public.level = "none"; + + const parentFolderAccess = accessInfo.entities.find( + a => a.type === "folder" && a.id === "parent", + ); + + if (!parentFolderAccess || parentFolderAccess.level === "none") { + return accessInfo; + } else if (item.parent_id !== "root" && item.parent_id !== "trash") { + // Get limitations from parent folder + const accessEntitiesFromParent = await makeStandaloneAccessLevel( + companyId, + item.parent_id, + null, + repository, + options, + ); + + let mostRestrictiveFolderLevel = parentFolderAccess.level as DriveFileAccessLevel | "none"; + + const keptEntities = accessEntitiesFromParent.entities.filter(a => { + if (["user", "channel"].includes(a.type)) { + return !accessInfo.entities.find(b => b.type === a.type && b.id === a.id); + } else { + if (a.type === "folder" && a.id === "parent") { + mostRestrictiveFolderLevel = hasAccessLevel(a.level, mostRestrictiveFolderLevel) + ? a.level + : mostRestrictiveFolderLevel; + } + return false; + } + }); + + accessInfo.entities = accessInfo.entities.map(a => { + if (a.type === "folder" && a.id === "parent") { + a.level = mostRestrictiveFolderLevel; + } + return a; + }) as DriveFile["access_info"]["entities"]; + + accessInfo.entities = [...accessInfo.entities, ...keptEntities]; + } + + return accessInfo; +}; diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index e1836b68..922fa187 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -27,17 +27,21 @@ import { addDriveItemToArchive, calculateItemSize, canMoveItem, - checkAccess, - getAccessLevel, getDefaultDriveItem, getDefaultDriveItemVersion, getFileMetadata, getItemName, getPath, - hasAccessLevel, - makeStandaloneAccessLevel, + getVirtualFoldersNames, + isVirtualFolder, updateItemSize, } from "../utils"; +import { + checkAccess, + getAccessLevel, + hasAccessLevel, + makeStandaloneAccessLevel, +} from "./access-check"; import { websocketEventBus } from "../../../core/platform/services/realtime/bus"; import archiver from "archiver"; @@ -96,19 +100,18 @@ export class DocumentsService { id = id || this.ROOT; //Get requested entity - const entity = - id === this.ROOT || id === this.TRASH - ? null - : await this.repository.findOne( - { - company_id: context.company.id, - id, - }, - {}, - context, - ); + const entity = isVirtualFolder(id) + ? null + : await this.repository.findOne( + { + company_id: context.company.id, + id, + }, + {}, + context, + ); - if (!entity && !(id === this.ROOT || id === this.TRASH)) { + if (!entity && !isVirtualFolder(id)) { this.logger.error("Drive item not found"); throw new CrudException("Item not found", 404); } @@ -171,9 +174,9 @@ export class DocumentsService { ({ id, parent_id: null, - name: id === this.ROOT ? "root" : id === this.TRASH ? "trash" : "unknown", + name: getVirtualFoldersNames(id), size: await calculateItemSize( - id === this.ROOT ? this.ROOT : "trash", + { id, is_directory: true, size: 0 }, this.repository, context, ), diff --git a/tdrive/backend/node/src/services/documents/utils.ts b/tdrive/backend/node/src/services/documents/utils.ts index 9915821a..aa82ee81 100644 --- a/tdrive/backend/node/src/services/documents/utils.ts +++ b/tdrive/backend/node/src/services/documents/utils.ts @@ -1,31 +1,38 @@ -import mimes from "../../utils/mime"; -import { merge } from "lodash"; -import { DriveFile } from "./entities/drive-file"; -import { - CompanyExecutionContext, - DriveExecutionContext, - DriveFileAccessLevel, - RootType, - TrashType, -} from "./types"; -import crypto from "crypto"; -import { FileVersion, DriveFileMetadata } from "./entities/file-version"; -import globalResolver from "../global-resolver"; -import Repository from "../../core/platform/services/database/services/orm/repository/repository"; import archiver from "archiver"; +import { merge } from "lodash"; +import PdfParse from "pdf-parse"; import { Readable } from "stream"; -import { stopWords } from "./const"; import unoconv from "unoconv-promise"; +import Repository from "../../core/platform/services/database/services/orm/repository/repository"; import { - writeToTemporaryFile, cleanFiles, getTmpFile, - readFromTemporaryFile, readableToBuffer, + readFromTemporaryFile, + writeToTemporaryFile, } from "../../utils/files"; -import PdfParse from "pdf-parse"; -import _ from "lodash"; -import { logger } from "../../core/platform/framework"; +import mimes from "../../utils/mime"; +import globalResolver from "../global-resolver"; +import { stopWords } from "./const"; +import { DriveFile } from "./entities/drive-file"; +import { DriveFileMetadata, FileVersion } from "./entities/file-version"; +import { checkAccess, generateAccessToken } from "./services/access-check"; +import { CompanyExecutionContext, DriveExecutionContext, RootType, TrashType } from "./types"; + +const ROOT: RootType = "root"; +const TRASH: TrashType = "trash"; + +export const isVirtualFolder = (id: string) => { + return id === ROOT || id === TRASH || id.startsWith("user_"); +}; + +export const getVirtualFoldersNames = (id: string) => { + if (id.startsWith("user_")) { + return "My Drive"; + } + + return id === ROOT ? "Home" : id === TRASH ? "Trash" : "Unknown"; +}; /** * Returns the default DriveFile object using existing data @@ -120,99 +127,6 @@ export const getDefaultDriveItemVersion = ( return defaultVersion; }; -/** - * Generates a random sha1 access token - * - * @returns {String} - the random access token ( sha1 hex digest ). - */ -export const generateAccessToken = (): string => { - const randomBytes = crypto.randomBytes(64); - - return crypto.createHash("sha1").update(randomBytes).digest("hex"); -}; - -/** - * Checks if the level meets the required level. - * - * @param {publicAccessLevel | DriveFileAccessLevel} requiredLevel - * @param {publicAccessLevel} level - * @returns {boolean} - */ -export const hasAccessLevel = ( - requiredLevel: DriveFileAccessLevel | "none", - level: DriveFileAccessLevel | "none", -): boolean => { - if (requiredLevel === level) return true; - - if (requiredLevel === "write") { - return level === "manage"; - } - - if (requiredLevel === "read") { - return level === "manage" || level === "write"; - } - - return requiredLevel === "none"; -}; - -/** - * checks the current user is a guest - * - * @param {CompanyExecutionContext} context - * @returns {Promise} - */ -export const isCompanyGuest = async (context: CompanyExecutionContext): Promise => { - if (context.user?.application_id) { - //Applications do everything (if they are added to the company) - if ( - !!( - await globalResolver.services.applications.companyApps.get({ - company_id: context.company.id, - application_id: context.user?.application_id, - }) - )?.application?.id - ) { - return false; - } - } - - const userRole = await globalResolver.services.companies.getUserRole( - context.company.id, - context.user?.id, - ); - - return userRole === "guest" || !userRole; -}; - -/** - * checks the current user is a admin - * - * @param {CompanyExecutionContext} context - * @returns {Promise} - */ -export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise => { - if (context.user?.application_id) { - //Applications do everything (if they are added to the company) - if ( - !!( - await globalResolver.services.applications.companyApps.get({ - company_id: context.company.id, - application_id: context.user?.application_id, - }) - )?.application?.id - ) { - return true; - } - } - - const userRole = await globalResolver.services.companies.getUserRole( - context.company.id, - context.user?.id, - ); - - return userRole === "admin"; -}; - /** * Calculates the size of the Drive Item * @@ -222,11 +136,11 @@ export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise< * @returns {Promise} - the size of the Drive Item */ export const calculateItemSize = async ( - item: DriveFile | TrashType | RootType, + item: DriveFile | { id: string; is_directory: boolean; size: number }, repository: Repository, context: CompanyExecutionContext, ): Promise => { - if (item === "trash") { + if (item.id === "trash") { const trashedItems = await repository.find( { company_id: context.company.id, parent_id: "trash" }, {}, @@ -236,9 +150,9 @@ export const calculateItemSize = async ( return trashedItems.getEntities().reduce((acc, curr) => acc + curr.size, 0); } - if (item === "root" || !item) { + if (isVirtualFolder(item.id) || !item) { const rootFolderItems = await repository.find( - { company_id: context.company.id, parent_id: "root" }, + { company_id: context.company.id, parent_id: item.id || "root" }, {}, context, ); @@ -275,7 +189,7 @@ export const updateItemSize = async ( repository: Repository, context: CompanyExecutionContext, ): Promise => { - if (!id || id === "root" || id === "trash") return; + if (!id || isVirtualFolder(id)) return; const item = await repository.findOne({ id, company_id: context.company.id }); @@ -287,7 +201,7 @@ export const updateItemSize = async ( await repository.save(item); - if (item.parent_id === "root" || item.parent_id === "trash") { + if (isVirtualFolder(item.parent_id)) { return; } @@ -310,12 +224,12 @@ export const getPath = async ( context?: DriveExecutionContext, ): Promise => { id = id || "root"; - if (id === "root" || id === "trash") + if (isVirtualFolder(id)) return !context.public_token || ignoreAccess ? [ { id, - name: id === "root" ? "Home" : "Trash", + name: getVirtualFoldersNames(id), } as DriveFile, ] : []; @@ -332,234 +246,6 @@ export const getPath = async ( return [...(await getPath(item.parent_id, repository, ignoreAccess, context)), item]; }; -/** - * checks if access can be granted for the drive item - * - * @param {string} id - * @param {DriveFile | null} item - * @param {DriveFileAccessLevel} level - * @param {Repository} repository - * @param {CompanyExecutionContext} context - * @param {string} token - * @returns {Promise} - */ -export const checkAccess = async ( - id: string, - item: DriveFile | null, - level: DriveFileAccessLevel, - repository: Repository, - context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string }, -): Promise => { - if (context.user?.server_request) { - return true; - } - - const grantedLevel = await getAccessLevel(id, item, repository, context); - const hasAccess = hasAccessLevel(level, grantedLevel); - logger.info( - `Got level ${grantedLevel} for drive item ${id} and required ${level} - returning ${hasAccess}`, - ); - return hasAccess; -}; - -/** - * get maximum level for the drive item - * - * @param {string} id - * @param {DriveFile | null} item - * @param {Repository} repository - * @param {CompanyExecutionContext} context - * @param {string} token - * @returns {Promise} - */ -export const getAccessLevel = async ( - id: string, - item: DriveFile | null, - repository: Repository, - context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string }, -): Promise => { - if (!id || id === "root") - return !context?.user?.id ? "none" : (await isCompanyGuest(context)) ? "read" : "manage"; - if (id === "trash") - return (await isCompanyGuest(context)) || !context?.user?.id - ? "none" - : (await isCompanyAdmin(context)) - ? "manage" - : "write"; - - let publicToken = context.public_token; - - try { - item = - item || - (await repository.findOne({ - id, - company_id: context.company.id, - })); - - if (!item) { - throw Error("Drive item doesn't exist"); - } - - if (context.user?.application_id) { - //Applications do everything (if they are added to the company) - if ( - !!( - await globalResolver.services.applications.companyApps.get({ - company_id: context.company.id, - application_id: context.user?.application_id, - }) - )?.application?.id - ) { - return "manage"; - } - } - - /* - * Specific user or channel rule is applied first. Then less restrictive level will be chosen - * between the parent folder and company accesses. - */ - - //Public access - if (publicToken) { - if (!item.access_info.public.token) return "none"; - 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; - } - - const accessEntities = item.access_info.entities || []; - const otherLevels = []; - - //From there a user must be logged in - if (context?.user?.id) { - //Users - const matchingUser = accessEntities.find(a => a.type === "user" && a.id === context.user?.id); - if (matchingUser) return matchingUser.level; - - //Channels - if (context.tdrive_tab_token) { - try { - const [channelId] = context.tdrive_tab_token.split("+"); //First item will be the channel id - const matchingChannel = accessEntities.find( - a => a.type === "channel" && a.id === channelId, - ); - if (matchingChannel) return matchingChannel.level; - } catch (e) { - console.log(e); - } - } - - //Companies - const matchingCompany = accessEntities.find( - a => a.type === "company" && a.id === context.company.id, - ); - if (matchingCompany) otherLevels.push(matchingCompany.level); - } - - //Parent folder - const maxParentFolderLevel = - accessEntities.find(a => a.type === "folder" && a.id === "parent")?.level || "none"; - if (maxParentFolderLevel === "none") { - otherLevels.push(maxParentFolderLevel); - } else { - const parentFolderLevel = await getAccessLevel(item.parent_id, null, repository, context); - otherLevels.push(parentFolderLevel); - } - - //Return least restrictive level of otherLevels - return otherLevels.reduce( - (previousValue, b) => - hasAccessLevel(b as DriveFileAccessLevel, previousValue as DriveFileAccessLevel) - ? previousValue - : b, - "none", - ) as DriveFileAccessLevel | "none"; - } catch (error) { - throw Error(error); - } -}; - -/** - * Isolate access level information from parent folder logic - * Used when putting folder in the trash - * @param id - * @param item - * @param repository - */ -export const makeStandaloneAccessLevel = async ( - companyId: string, - id: string, - item: DriveFile | null, - repository: Repository, - options: { removePublicAccess?: boolean } = { removePublicAccess: true }, -): Promise => { - item = - item || - (await repository.findOne({ - id, - company_id: companyId, - })); - - if (!item) { - throw Error("Drive item doesn't exist"); - } - - const accessInfo = _.cloneDeep(item.access_info); - - if (options?.removePublicAccess && accessInfo?.public?.level) accessInfo.public.level = "none"; - - const parentFolderAccess = accessInfo.entities.find( - a => a.type === "folder" && a.id === "parent", - ); - - if (!parentFolderAccess || parentFolderAccess.level === "none") { - return accessInfo; - } else if (item.parent_id !== "root" && item.parent_id !== "trash") { - // Get limitations from parent folder - const accessEntitiesFromParent = await makeStandaloneAccessLevel( - companyId, - item.parent_id, - null, - repository, - options, - ); - - let mostRestrictiveFolderLevel = parentFolderAccess.level as DriveFileAccessLevel | "none"; - - const keptEntities = accessEntitiesFromParent.entities.filter(a => { - if (["user", "channel"].includes(a.type)) { - return !accessInfo.entities.find(b => b.type === a.type && b.id === a.id); - } else { - if (a.type === "folder" && a.id === "parent") { - mostRestrictiveFolderLevel = hasAccessLevel(a.level, mostRestrictiveFolderLevel) - ? a.level - : mostRestrictiveFolderLevel; - } - return false; - } - }); - - accessInfo.entities = accessInfo.entities.map(a => { - if (a.type === "folder" && a.id === "parent") { - a.level = mostRestrictiveFolderLevel; - } - return a; - }) as DriveFile["access_info"]["entities"]; - - accessInfo.entities = [...accessInfo.entities, ...keptEntities]; - } - - return accessInfo; -}; - /** * Adds drive items to an archive recursively * @@ -798,23 +484,20 @@ export const canMoveItem = async ( context: CompanyExecutionContext, ): Promise => { if (source === target) return false; - if (target === "root" || target === "trash") return true; const item = await repository.findOne({ id: source, company_id: context.company.id, }); - if (!item.is_directory) { - return true; - } + const targetItem = isVirtualFolder(target) + ? null + : await repository.findOne({ + id: target, + company_id: context.company.id, + }); - const targetItem = await repository.findOne({ - id: target, - company_id: context.company.id, - }); - - if (!targetItem || !targetItem.is_directory) { + if (!isVirtualFolder(target) && (!targetItem || !targetItem.is_directory)) { throw Error("target item doesn't exist or not a directory"); } diff --git a/tdrive/backend/node/test/e2e/documents/documents.spec.ts b/tdrive/backend/node/test/e2e/documents/documents.spec.ts index d964740e..51537b3e 100644 --- a/tdrive/backend/node/test/e2e/documents/documents.spec.ts +++ b/tdrive/backend/node/test/e2e/documents/documents.spec.ts @@ -100,7 +100,8 @@ describe("the Drive feature", () => { const response = await e2e_getDocument(platform, ""); const result = deserialize(DriveItemDetailsMockClass, response.body); - expect(result.item.name).toEqual("root"); + expect(result.item.id).toEqual("root"); + expect(result.item.name).toEqual("Home"); done?.(); }); @@ -111,7 +112,8 @@ describe("the Drive feature", () => { const response = await e2e_getDocument(platform, "trash"); const result = deserialize(DriveItemDetailsMockClass, response.body); - expect(result.item.name).toEqual("trash"); + expect(result.item.id).toEqual("trash"); + expect(result.item.name).toEqual("Trash"); done?.(); }); @@ -162,7 +164,7 @@ describe("the Drive feature", () => { listTrashResponse.body, ); - expect(listTrashResult.item.name).toEqual("trash"); + expect(listTrashResult.item.name).toEqual("Trash"); expect(listTrashResult.children.some(({ id }) => id === createItemResult.id)).toBeTruthy(); done?.(); diff --git a/tdrive/backend/node/test/e2e/documents/my-drive.spec.ts b/tdrive/backend/node/test/e2e/documents/my-drive.spec.ts new file mode 100644 index 00000000..bc8ce64a --- /dev/null +++ b/tdrive/backend/node/test/e2e/documents/my-drive.spec.ts @@ -0,0 +1,137 @@ +import { describe, beforeEach, afterEach, it, expect, afterAll } from "@jest/globals"; +import { deserialize } from "class-transformer"; +import { File } from "../../../src/services/files/entities/file"; +import { ResourceUpdateResponse } from "../../../src/utils/types"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { + e2e_createDocument, + e2e_createDocumentFile, + e2e_createVersion, + e2e_deleteDocument, + e2e_getDocument, + e2e_searchDocument, + e2e_updateDocument, +} from "./utils"; + +describe("the My Drive feature", () => { + let platform: TestPlatform; + + class DriveFileMockClass { + id: string; + name: string; + size: number; + added: string; + parent_id: string; + } + + class DriveItemDetailsMockClass { + path: string[]; + item: DriveFileMockClass; + children: DriveFileMockClass[]; + versions: Record[]; + } + + class SearchResultMockClass { + entities: DriveFileMockClass[]; + } + + beforeEach(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", + ], + }); + }); + + afterEach(async () => { + await platform.tearDown(); + }); + + afterAll(async () => { + await platform.app.close(); + }); + + const createItem = async (): Promise => { + await TestDbService.getInstance(platform, true); + + const item = { + name: "new test file", + parent_id: "user_" + platform.currentUser.id, + company_id: platform.workspace.company_id, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + return deserialize(DriveFileMockClass, response.body); + }; + + it("did create the drive item in my user folder", async done => { + const result = await createItem(); + + expect(result).toBeDefined(); + expect(result.name).toEqual("new test file"); + expect(result.added).toBeDefined(); + + done?.(); + }); + + it("did move an item to root and back", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + let updateItemResponse = await e2e_updateDocument(platform, createItemResult.id, { + parent_id: "root", + }); + let updateItemResult = deserialize( + DriveFileMockClass, + updateItemResponse.body, + ); + + expect(createItemResult.id).toEqual(updateItemResult.id); + expect(updateItemResult.parent_id).toEqual("root"); + + updateItemResponse = await e2e_updateDocument(platform, createItemResult.id, { + parent_id: "user_" + platform.currentUser.id, + }); + updateItemResult = deserialize(DriveFileMockClass, updateItemResponse.body); + + expect(createItemResult.id).toEqual(updateItemResult.id); + expect(updateItemResult.parent_id).toEqual("user_" + platform.currentUser.id); + + done?.(); + }); + + it("can't move an item to another user folder", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + let updateItemResponse = await e2e_updateDocument(platform, createItemResult.id, { + parent_id: "user_2123", + }); + + expect(updateItemResponse.statusCode).not.toBe(200); + + done?.(); + }); +}); diff --git a/tdrive/frontend/src/app/views/client/body/drive/header-path.tsx b/tdrive/frontend/src/app/views/client/body/drive/header-path.tsx index 8aea0046..590fb6f2 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/header-path.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/header-path.tsx @@ -1,8 +1,11 @@ import { Button } from '@atoms/button/button'; import { Title } from '@atoms/text'; import { DriveItem } from '@features/drive/types'; +import { ChevronDownIcon } from '@heroicons/react/solid'; import { useEffect, useState } from 'react'; import { PublicIcon } from './components/public-icon'; +import MenusManager from '@components/menus/menus-manager.jsx'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; export default ({ path: livePath, @@ -56,6 +59,7 @@ const PathItem = ({ first?: boolean; onClick: (id: string) => void; }) => { + const { user } = useCurrentUser(); return ( ); }; diff --git a/tdrive/frontend/src/app/views/client/side-bar/index.tsx b/tdrive/frontend/src/app/views/client/side-bar/index.tsx index 8820cea7..24a732c6 100644 --- a/tdrive/frontend/src/app/views/client/side-bar/index.tsx +++ b/tdrive/frontend/src/app/views/client/side-bar/index.tsx @@ -6,7 +6,9 @@ import { HeartIcon, ShareIcon, TrashIcon, + UserIcon, } from '@heroicons/react/outline'; +import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; import { useRecoilState } from 'recoil'; import { Title } from '../../../atoms/text'; import { useDriveItem } from '../../../features/drive/hooks/use-drive-item'; @@ -18,10 +20,14 @@ import Actions from './actions'; export default () => { const [parentId, setParentId] = useRecoilState(DriveCurrentFolderAtom('root')); + const { user } = useCurrentUser(); const active = false; const { access: rootAccess } = useDriveItem('root'); - const { inTrash } = useDriveItem(parentId); + const { inTrash, path } = useDriveItem(parentId); const activeClass = 'bg-zinc-50 dark:bg-zinc-800 !text-blue-500'; + let folderType = 'home'; + if ((path || [])[0]?.id === 'user_' + user?.id) folderType = 'personal'; + if (inTrash) folderType = 'trash'; return (
@@ -45,10 +51,18 @@ export default () => { onClick={() => setParentId('root')} size="lg" theme="white" - className={'w-full mt-2 mb-1 ' + (!inTrash ? activeClass : '')} + className={'w-full mt-2 mb-1 ' + (folderType === 'home' ? activeClass : '')} > Home + {false && ( <>