⭐️ Implement personal drive (#61)
* Implement personal drive * Fix tests * Add my-drive tests * Fixing tests * Fixing tests * Fix tests
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user