️ 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
@@ -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");
}