️ Implement personal drive (#61)

* Implement personal drive

* Fix tests

* Add my-drive tests

* Fixing tests

* Fixing tests

* Fix tests
This commit is contained in:
Romaric Mourgues
2023-05-30 11:15:43 +02:00
committed by GitHub
parent 02ccb279f7
commit 5e655996c7
9 changed files with 591 additions and 388 deletions
+3 -3
View File
@@ -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.
@@ -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: {
@@ -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<boolean>}
*/
export const isCompanyGuest = async (context: CompanyExecutionContext): Promise<boolean> => {
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<boolean>}
*/
export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise<boolean> => {
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<boolean>}
*/
export const isCompanyApplication = async (context: CompanyExecutionContext): Promise<boolean> => {
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<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const checkAccess = async (
id: string,
item: DriveFile | null,
level: DriveFileAccessLevel,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<boolean> => {
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<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const getAccessLevel = async (
id: string,
item: DriveFile | null,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<DriveFileAccessLevel | "none"> => {
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<DriveFile>,
options: { removePublicAccess?: boolean } = { removePublicAccess: true },
): Promise<DriveFile["access_info"]> => {
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;
};
@@ -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,
),
@@ -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<boolean>}
*/
export const isCompanyGuest = async (context: CompanyExecutionContext): Promise<boolean> => {
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<boolean>}
*/
export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise<boolean> => {
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<number>} - 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<DriveFile>,
context: CompanyExecutionContext,
): Promise<number> => {
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<DriveFile>,
context: CompanyExecutionContext,
): Promise<void> => {
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<DriveFile[]> => {
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<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const checkAccess = async (
id: string,
item: DriveFile | null,
level: DriveFileAccessLevel,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<boolean> => {
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<DriveFile>} repository
* @param {CompanyExecutionContext} context
* @param {string} token
* @returns {Promise<boolean>}
*/
export const getAccessLevel = async (
id: string,
item: DriveFile | null,
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<DriveFileAccessLevel | "none"> => {
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<DriveFile>,
options: { removePublicAccess?: boolean } = { removePublicAccess: true },
): Promise<DriveFile["access_info"]> => {
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<boolean> => {
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");
}
@@ -100,7 +100,8 @@ describe("the Drive feature", () => {
const response = await e2e_getDocument(platform, "");
const result = deserialize<DriveItemDetailsMockClass>(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>(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?.();
@@ -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<string, unknown>[];
}
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<DriveFileMockClass> => {
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>(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>(
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>(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?.();
});
});
@@ -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 (
<Button
theme={last ? 'primary' : 'default'}
@@ -65,8 +69,19 @@ const PathItem = ({
(!last ? 'rounded-r-none ' : '') +
(!first && !last ? 'max-w-[15ch] ' : '')
}
onClick={() => {
onClick(item?.id || '');
onClick={evt => {
if (first) {
MenusManager.openMenu(
[
{ type: 'menu', text: 'Home', onClick: () => onClick('root') },
{ type: 'menu', text: 'My Drive', onClick: () => onClick('user_' + user?.id) },
],
{ x: evt.clientX, y: evt.clientY },
'center',
);
} else {
onClick(item?.id || '');
}
}}
>
<span className="text-ellipsis overflow-hidden whitespace-nowrap" style={{ maxWidth: 120 }}>
@@ -75,6 +90,11 @@ const PathItem = ({
{item?.access_info?.public?.level && item?.access_info?.public?.level !== 'none' && (
<PublicIcon className="h-5 w-5 ml-2" />
)}
{first && (
<span className="ml-2 -mr-1">
<ChevronDownIcon className="w-4 h-4" />
</span>
)}
</Button>
);
};
@@ -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 (
<div className="grow flex flex-col overflow-auto -m-4 p-4 relative">
<div className="grow">
@@ -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 : '')}
>
<CloudIcon className="w-5 h-5 mr-4" /> Home
</Button>
<Button
onClick={() => setParentId('user_' + user?.id)}
size="lg"
theme="white"
className={'w-full mb-1 ' + (folderType === 'personal' ? activeClass : '')}
>
<UserIcon className="w-5 h-5 mr-4" /> My Drive
</Button>
{false && (
<>
<Button
@@ -72,7 +86,7 @@ export default () => {
onClick={() => setParentId('trash')}
size="lg"
theme="white"
className={'w-full mb-1 ' + (inTrash ? activeClass : '')}
className={'w-full mb-1 ' + (folderType === 'trash' ? activeClass : '')}
>
<TrashIcon className="w-5 h-5 mr-4 text-rose-500" /> Trash
</Button>