🍄 Separate trash for the personal and shared drive (#189)

This commit is contained in:
Montassar Ghanmy
2023-10-06 17:05:07 +01:00
committed by GitHub
parent f0dcf34bbe
commit edfd765d57
34 changed files with 440 additions and 185 deletions
@@ -5,7 +5,7 @@ import { defer, Subject, throwError, timer } from "rxjs";
import { concat, delayWhen, retryWhen, take, tap } from "rxjs/operators";
import { UpsertOptions } from "..";
import { logger } from "../../../../../../framework";
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
import { getEntityDefinition, unwrapPrimarykey } from "../../utils";
import { EntityDefinition, ColumnDefinition, ObjectType } from "../../types";
import { AbstractConnector } from "../abstract-connector";
import {
@@ -451,22 +451,6 @@ export class CassandraConnector extends AbstractConnector<
const instance = new (entityType as any)();
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
const pk = unwrapPrimarykey(entityDefinition);
const indexes = unwrapIndexes(entityDefinition);
if (
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
) {
//Filter not in primary key
throw new Error(
`All filter parameters must be defined in entity primary key,
got: ${JSON.stringify(Object.keys(filters))}
on table ${entityDefinition.name} but pk is ${JSON.stringify(pk)},
instance was ${JSON.stringify(instance)}`,
);
}
const query = buildSelectQuery<Table>(
entityType as unknown as ObjectType<Table>,
filters,
@@ -1,6 +1,6 @@
import { FindOptions } from "../../repository/repository";
import { ObjectType } from "../../types";
import { getEntityDefinition, secureOperators } from "../../utils";
import { getEntityDefinition, secureOperators, filteringRequired } from "../../utils";
import { transformValueToDbString } from "./typeTransforms";
export function buildSelectQuery<Entity>(
@@ -17,12 +17,17 @@ export function buildSelectQuery<Entity>(
): string {
const instance = new (entityType as any)();
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
let allowFiltering = false;
const where = Object.keys(filters)
.map(key => {
let result: string;
const filter = filters[key];
if (filteringRequired(key)) {
allowFiltering = true;
}
if (!filter) {
return;
}
@@ -77,7 +82,7 @@ export function buildSelectQuery<Entity>(
whereClause.trim().length ? "WHERE " + whereClause : ""
} ${orderByClause.trim().length ? "ORDER BY " + orderByClause : ""}`
.trimEnd()
.concat(";");
.concat(allowFiltering ? " ALLOW FILTERING;" : ";");
return query;
}
@@ -3,7 +3,7 @@ import { UpsertOptions } from "..";
import { ListResult, Paginable, Pagination } from "../../../../../../framework/api/crud-service";
import { FindOptions } from "../../repository/repository";
import { ColumnDefinition, EntityDefinition, ObjectType } from "../../types";
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
import { getEntityDefinition, unwrapPrimarykey } from "../../utils";
import { AbstractConnector } from "../abstract-connector";
import { buildSelectQuery } from "./query-builder";
import { transformValueFromDbString, transformValueToDbString } from "./typeTransforms";
@@ -186,24 +186,6 @@ export class MongoConnector extends AbstractConnector<MongoConnectionOptions, mo
const instance = new (entityType as any)();
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
const pk = unwrapPrimarykey(entityDefinition);
const indexes = unwrapIndexes(entityDefinition);
if (
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
) {
//Filter not in primary key
throw Error(
"All filter parameters must be defined in entity primary key, got: " +
JSON.stringify(Object.keys(filters)) +
" on table " +
entityDefinition.name +
" but pk is " +
JSON.stringify(pk),
);
}
const db = await this.getDatabase();
const collection = db.collection(`${entityDefinition.name}`);
@@ -106,3 +106,12 @@ export function toMongoDbOrderable(timeuuid?: string): string {
const time_str = [uuid_arr[2], uuid_arr[1], uuid_arr[0], uuid_arr[3], uuid_arr[4]].join("-");
return time_str;
}
/**
* Check if filtering is necessary
* @param {string} key
* @returns {boolean} Returns true if key is "is_in_trash" or "scope", otherwise returns false.
*/
export const filteringRequired = (key: string) => {
return key === "is_in_trash" || key === "scope";
};
@@ -5,9 +5,13 @@ import { FileVersion } from "./file-version";
import search from "./drive-file.search";
export const TYPE = "drive_files";
export type DriveScope = "personal" | "shared";
@Entity(TYPE, {
globalIndexes: [["company_id", "parent_id"]],
globalIndexes: [
["company_id", "parent_id"],
["company_id", "is_in_trash"],
],
primaryKey: [["company_id"], "id"],
type: TYPE,
search,
@@ -73,6 +77,10 @@ export class DriveFile {
@Column("last_version_cache", "encoded_json")
last_version_cache: Partial<FileVersion>;
@Type(() => String)
@Column("scope", "string")
scope: DriveScope;
}
export type AccessInformation = {
@@ -169,28 +169,61 @@ export const getAccessLevel = async (
repository: Repository<DriveFile>,
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
): Promise<DriveFileAccessLevel | "none"> => {
const isAdmin = !context?.user?.id || (await isCompanyAdmin(context));
const isMember = !context?.user?.id || (await isCompanyMember(context));
if (!id || id === "root") {
if (!context?.user?.id || (await isCompanyGuest(context))) {
return "none";
} else {
if (isMember) return "read";
return "manage";
}
}
if (id === "trash")
return (await isCompanyGuest(context)) || !context?.user?.id
? "none"
: (await isCompanyAdmin(context))
? "manage"
: "write";
if (id === "shared_with_me") return "read";
//If it is my personal folder, I have full access
if (id.startsWith("user_")) {
if (await isCompanyApplication(context)) return "read";
}
if (context?.user?.id) {
/**
* Drive root directories access management
*/
if (id === "user_" + context.user?.id) return "manage";
if (await isCompanyApplication(context)) return "manage";
return "none";
if (id === "trash_" + context.user?.id) return "manage";
if (id === "root") {
if (isAdmin) return "manage";
return "read";
}
if (id === "trash") {
if (isAdmin) return "manage";
return "read";
}
if (id === "shared_with_me") return "read";
/**
* Entity based access management
*/
if (!item) {
item = await repository.findOne({
id,
company_id: context.company.id,
});
}
if (!item) {
throw Error("Drive item doesn't exist");
}
if (item.scope === "personal" && item.creator == context.user.id) return "manage";
if (item.scope === "shared") {
if (isAdmin) return "manage";
if (isMember) return "read";
}
}
let publicToken = context.public_token;
@@ -208,13 +241,6 @@ export const getAccessLevel = async (
throw Error("Drive item doesn't exist");
}
if (await isCompanyApplication(context)) {
if (!id.startsWith("user_") && isMember && item.creator != context.user.id) {
return "read";
}
return "manage";
}
/*
* Specific user or channel rule is applied first. Then less restrictive level will be chosen
* between the parent folder and company accesses.
@@ -383,6 +409,30 @@ export const getSharedByUser = (
return null;
};
export const getItemScope = async (
item: DriveFile | null,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
): Promise<"personal" | "shared"> => {
let scope: "personal" | "shared";
if (item.parent_id === "user_" + context.user?.id) {
scope = "personal";
} else if (item.parent_id === "root") {
scope = "shared";
} else {
const driveItemParent = await repository.findOne(
{
company_id: context.company.id,
id: item.parent_id,
},
{},
context,
);
scope = driveItemParent.scope;
}
return scope;
};
const getGrantorAndThrowIfEmpty = (entity: AuthEntity) => {
if (!entity.grantor) {
logger.warn(`For the file permissions ${entity.id} grantor is not defined`);
@@ -34,6 +34,7 @@ export class DriveFileDTOBuilder {
"access_info",
"content_keywords",
"creator",
"scope",
],
],
[
@@ -40,12 +40,14 @@ import {
isSharedWithMeFolder,
isVirtualFolder,
updateItemSize,
isInTrash,
} from "../utils";
import {
checkAccess,
getAccessLevel,
hasAccessLevel,
makeStandaloneAccessLevel,
getItemScope,
} from "./access-check";
import { websocketEventBus } from "../../../core/platform/services/realtime/bus";
@@ -153,7 +155,6 @@ export class DocumentsService {
throw Error("user does not have access to this item");
}
} catch (error) {
console.log(error);
this.logger.error({ error: `${error}` }, "Failed to grant access to the drive item");
throw new CrudException("User does not have access to this item or its children", 401);
}
@@ -179,7 +180,22 @@ export class DocumentsService {
await this.repository.find(
{
company_id: context.company.id,
parent_id: id,
...(id.includes(this.TRASH)
? {
is_in_trash: true,
...(id == this.TRASH
? {
scope: "shared",
}
: {
scope: "personal",
creator: context?.user?.id,
}),
}
: {
parent_id: id,
is_in_trash: false,
}),
},
{},
context,
@@ -257,6 +273,7 @@ export class DocumentsService {
try {
const driveItem = getDefaultDriveItem(content, context);
const driveItemVersion = getDefaultDriveItemVersion(version, context);
driveItem.scope = await getItemScope(driveItem, this.repository, context);
const hasAccess = await checkAccess(
driveItem.parent_id,
@@ -387,7 +404,7 @@ export class DocumentsService {
throw Error("content mismatch");
}
const updatable = ["access_info", "name", "tags", "parent_id", "description"];
const updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"];
for (const key of updatable) {
if ((content as any)[key]) {
@@ -434,6 +451,9 @@ export class DocumentsService {
await updateItemSize(item.parent_id, this.repository, context);
if (oldParent) {
item.scope = await getItemScope(item, this.repository, context);
this.repository.save(item);
await updateItemSize(oldParent, this.repository, context);
this.notifyWebsocket(oldParent, context);
}
@@ -529,6 +549,7 @@ export class DocumentsService {
const previousParentId = item.parent_id;
if (
(await isInTrash(item, this.repository, context)) ||
item.parent_id === this.TRASH ||
(await getPath(item.parent_id, this.repository, true, context))[0].id === this.TRASH
) {
@@ -568,7 +589,7 @@ export class DocumentsService {
await this.repository.remove(item);
} else {
//This item is not in trash, we move it to trash
item.parent_id = this.TRASH;
item.is_in_trash = true;
await this.update(item.id, item, context);
}
await updateItemSize(previousParentId, this.repository, context);
@@ -579,6 +600,61 @@ export class DocumentsService {
await this.notifyWebsocket("trash", context);
};
/**
* restore a Drive Document and its children
*
* @param {string} id - the item id
* @param {DriveExecutionContext} context - the execution context
* @returns {Promise<void>}
*/
restore = async (
id: string | RootType | TrashType,
item?: DriveFile,
context?: DriveExecutionContext,
): Promise<void> => {
if (!id) {
//We can't remove the root folder
return;
}
item =
item ||
(await this.repository.findOne({
company_id: context.company.id,
id,
}));
if (!item) {
this.logger.error("item to delete not found");
throw new CrudException("Drive item not found", 404);
}
try {
if (!(await checkAccess(item.id, item, "manage", this.repository, context))) {
this.logger.error("user does not have access drive item ", id);
throw Error("user does not have access to this item");
}
} catch (error) {
this.logger.error({ error: `${error}` }, "Failed to grant access to the drive item");
throw new CrudException("User does not have access to this item or its children", 401);
}
if (isInTrash(item, this.repository, context)) {
if (item.is_in_trash != true) {
if (item.scope === "personal") {
item.parent_id = "user_" + context.user.id;
} else {
item.parent_id = "root";
}
} else {
item.is_in_trash = false;
}
}
this.repository.save(item);
this.notifyWebsocket("trash", context);
};
/**
* Create a Drive item version
*
@@ -30,7 +30,13 @@ const TRASH: TrashType = "trash";
const SHARED_WITH_ME: SharedWithMeType = "shared_with_me";
export const isVirtualFolder = (id: string) => {
return id === ROOT || id === TRASH || id.startsWith("user_") || id == SHARED_WITH_ME;
return (
id === ROOT ||
id === TRASH ||
id.startsWith("trash_") ||
id.startsWith("user_") ||
id == SHARED_WITH_ME
);
};
export const isSharedWithMeFolder = (id: string) => {
@@ -72,6 +78,7 @@ export const getDefaultDriveItem = (
last_modified: new Date().getTime(),
parent_id: item.parent_id || "root",
content_keywords: item.content_keywords || "",
scope: "personal",
description: item.description || "",
access_info: item.access_info || {
entities: [
@@ -163,7 +170,17 @@ export const calculateItemSize = async (
): Promise<number> => {
if (item.id === "trash") {
const trashedItems = await repository.find(
{ company_id: context.company.id, parent_id: "trash" },
{ company_id: context.company.id, is_in_trash: true, scope: "shared" },
{},
context,
);
return trashedItems.getEntities().reduce((acc, curr) => acc + curr.size, 0);
}
if (item.id === "trash_" + context.user.id) {
const trashedItems = await repository.find(
{ company_id: context.company.id, is_in_trash: true, scope: "personal" },
{},
context,
);
@@ -173,7 +190,7 @@ export const calculateItemSize = async (
if (isVirtualFolder(item.id) || !item) {
const rootFolderItems = await repository.find(
{ company_id: context.company.id, parent_id: item.id || "root" },
{ company_id: context.company.id, parent_id: item.id || "root", is_in_trash: false },
{},
context,
);
@@ -186,6 +203,7 @@ export const calculateItemSize = async (
{
company_id: context.company.id,
parent_id: item.id,
is_in_trash: false,
},
{},
context,
@@ -561,3 +579,26 @@ export function isFileType(
const fileExtensions = [extension, ...secondaryExtensions];
return fileExtensions.some(e => requiredExtensions.includes(e));
}
export const isInTrash = async (
item: DriveFile,
repository: Repository<DriveFile>,
context: CompanyExecutionContext,
) => {
if (item.is_in_trash === true) {
return true;
}
if (isVirtualFolder(item.parent_id)) {
return false; // Stop condition
}
// Retrieve the parent item
const parentItem = await repository.findOne({
company_id: context.company.id,
id: item.parent_id,
});
// Recursively check the parent item
return isInTrash(parentItem, repository, context);
};
@@ -114,6 +114,29 @@ export class DocumentsController {
}
};
/**
* Restore a DriveFile item from the trashÒ
*
* @param {FastifyRequest} request
* @param {FastifyReply} reply
* @returns {Promise<void>}
*/
restore = async (
request: FastifyRequest<{ Params: ItemRequestParams; Querystring: { public_token?: string } }>,
reply: FastifyReply,
): Promise<void> => {
try {
const context = getDriveExecutionContext(request);
await globalResolver.services.documents.documents.restore(request.params.id, null, context);
reply.status(200).send();
} catch (error) {
logger.error({ error: `${error}` }, "Failed to restore drive item");
throw new CrudException("Failed to restore drive item", 500);
}
};
/**
* Lists the drive root folder.
*
@@ -51,6 +51,13 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next)
handler: documentsController.delete.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${serviceUrl}/:id/restore`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.restore.bind(documentsController),
});
fastify.route({
method: "GET",
url: `${serviceUrl}/:id/user/:user_id/access`,
@@ -83,6 +83,7 @@ const documentSchema = {
last_user: { type: "string" },
attachements: { type: "array" },
last_version_cache: fileVersionSchema,
scope: { type: "string" },
},
};