Merge branch 'main' into release/v1.0.4

This commit is contained in:
ericlinagora
2024-10-15 21:48:02 +02:00
committed by GitHub
60 changed files with 3271 additions and 3291 deletions
@@ -128,8 +128,8 @@
"defaultLanguage": "DRIVE_DEFAULT_LANGUAGE",
"defaultCompany": "DRIVE_DEFAULT_COMPANY",
"defaultUserQuota": "DRIVE_DEFAULT_USER_QUOTA",
"featureSearchUsers": "ENABLE_FEATURE_SEARCH_USERS",
"featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL",
"featureUserQuota": "ENABLE_FEATURE_USER_QUOTA"
"featureUserQuota": "ENABLE_FEATURE_USER_QUOTA",
"featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS"
}
}
+2 -2
View File
@@ -68,7 +68,7 @@
"mongodb": {
"uri": "mongodb://mongo:27017",
"database": "tdrive"
},
}
},
"message-queue": {
"// possible 'type' values are": "'amqp' or 'local'",
@@ -114,9 +114,9 @@
},
"drive": {
"featureSharedDrive": true,
"featureSearchUsers": true,
"featureDisplayEmail": true,
"featureUserQuota": false,
"featureManageAccess": true,
"defaultCompany": "00000000-0000-4000-0000-000000000000",
"defaultUserQuota": 200000000
},
+5 -5
View File
@@ -1,6 +1,6 @@
{
"hello": "Hello",
"virtual-folder.shared-drive": "Shared Drive",
"virtual-folder.my-drive": "My Drive",
"virtual-folder.trash": "Trash"
}
"hello": "Hello",
"virtual-folder.my-drive": "My Drive",
"virtual-folder.shared-drive": "Shared Drive",
"virtual-folder.trash": "Trash"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"hello": "Bonjour",
"virtual-folder.shared-drive": "Disque Partagé",
"virtual-folder.my-drive": "Mon disque",
"virtual-folder.shared-drive": "Disque Partagé",
"virtual-folder.trash": "Corbeille"
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"hello": "Привет",
"virtual-folder.shared-drive": "Общий Диск",
"virtual-folder.my-drive": "Мой Диск",
"virtual-folder.trash": "Корзина"
}
"hello": "Привет",
"virtual-folder.my-drive": "Мой Диск",
"virtual-folder.shared-drive": "Общий Диск",
"virtual-folder.trash": "Корзина"
}
+6
View File
@@ -0,0 +1,6 @@
{
"hello": "Xin chào",
"virtual-folder.my-drive": "Drive của tôi",
"virtual-folder.shared-drive": "Drive chia sẻ",
"virtual-folder.trash": "Thùng rác"
}
-6
View File
@@ -1,6 +0,0 @@
{
"hello": "Xin chào",
"virtual-folder.shared-drive": "Drive chia sẻ",
"virtual-folder.my-drive": "Drive của tôi",
"virtual-folder.trash": "Thùng rác"
}
@@ -4,18 +4,15 @@ import parseYargsCommaSeparatedStringArray from "../../utils/yargs-comma-array";
import { NonPlatformCommandYargsBuilder } from "../../utils/non-plaform-command-yargs-builder";
import { openWithSystemViewer } from "../../../utils/exec";
const CORE_LANGUAGES = "en fr ru vn".split(" ");
// const iso639ToTwakeDriveISO = set1 => (set1 === "vi" ? "vn" : set1);
const twakeDriveISOToISO639 = twakeLang => (twakeLang === "vn" ? "vi" : twakeLang);
const CORE_LANGUAGES = "en fr ru vi".split(" ");
const TEMPLATE_VAR_NAME = "TDCLI_TRANSLATOR_URL";
const urlToTranslate = (text: string, to: string, from?: string) => {
const template = process.env[TEMPLATE_VAR_NAME];
if (!template) throw new Error(`${TEMPLATE_VAR_NAME} environment variable must be set.`);
const variables = {
to: twakeDriveISOToISO639(to),
from: from ? twakeDriveISOToISO639(from) : from,
to,
from,
text,
};
return template.replace(/%\{([^}:]+)(?::([^}]+))?\}/g, (_, varName, absentValue) => {
@@ -94,6 +94,10 @@ async function report(platform: TdrivePlatform, args: ListArguments) {
` - ${formatTS(version.date_added)} by ${await formatUser(version.creator_id)}`,
);
console.error(` - id: ${version.id}`);
if (dfile.name != version.filename)
console.error(` - filename: ${JSON.stringify(version.filename)}`);
if (dfile.name != version.file_metadata.name)
console.error(` - meta.name: ${JSON.stringify(version.file_metadata.name)}`);
console.error(
` - size: ${version.file_metadata.size} (${
version.file_metadata.size > previousSize ? "+" : ""
@@ -160,6 +160,17 @@ export class CrudException extends Error {
}
export interface Paginable {
/**
* In page token can be different type of data depending of the source.
* For ES it will be exactly page token, identifier of the next page.
* For PostgreSQL it will be number of the next page.
* For MongoDB it will be offset.
* This information is relevant for the Service/Repository level, in the controller
* we have only offset there for every type of source because in the "browse" controller
* we do not pass "next_page_token" to frontend and hence we are calculating
* offset on the frontend side and then passing offset to DocumentService.
*
*/
page_token?: string;
limitStr?: string;
reversed?: boolean;
@@ -3,7 +3,7 @@ import { Connector, UpsertOptions } from ".";
import { ConnectionOptions, DatabaseType } from "../..";
import { FindOptions } from "../repository/repository";
import { ColumnDefinition, EntityDefinition } from "../types";
import { ListResult } from "../../../../../framework/api/crud-service";
import { ListResult, Paginable, Pagination } from "../../../../../framework/api/crud-service";
export abstract class AbstractConnector<T extends ConnectionOptions> implements Connector {
constructor(protected type: DatabaseType, protected options: T, protected secret: string) {}
@@ -38,6 +38,8 @@ export abstract class AbstractConnector<T extends ConnectionOptions> implements
options: FindOptions,
): Promise<ListResult<EntityType>>;
abstract getOffsetPagination(options: Paginable): Pagination;
getOptions(): T {
return this.options;
}
@@ -3,7 +3,7 @@ import { DatabaseType } from "../..";
import { MongoConnectionOptions } from "./mongodb/mongodb";
import { ColumnDefinition, EntityDefinition } from "../types";
import { FindOptions } from "../repository/repository";
import { ListResult } from "../../../../../framework/api/crud-service";
import { ListResult, Paginable, Pagination } from "../../../../../framework/api/crud-service";
import { PostgresConnectionOptions } from "./postgres/postgres";
export * from "./mongodb/mongodb";
@@ -92,6 +92,13 @@ export interface Connector extends Initializable {
filters: any,
options: FindOptions,
): Promise<ListResult<EntityType>>;
/**
* Get the pagination for the given options where the pagination is offset based
* @param options Paginable
* @returns Pagination
*/
getOffsetPagination(options: Paginable): Pagination;
}
export declare type ConnectionOptions = MongoConnectionOptions | PostgresConnectionOptions;
@@ -316,4 +316,9 @@ export class MongoConnector extends AbstractConnector<MongoConnectionOptions> {
const nextPage: Paginable = new Pagination(nextToken, options.pagination.limitStr || "100");
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
}
getOffsetPagination(options: Paginable): Pagination {
const { page_token, limitStr } = options;
return new Pagination(`${page_token}`, `${limitStr}`, options.reversed);
}
}
@@ -358,6 +358,12 @@ export class PostgresConnector extends AbstractConnector<PostgresConnectionOptio
throw err;
}
}
getOffsetPagination(options: Paginable): Pagination {
const { page_token, limitStr } = options;
const pageNumber = parseInt(page_token) / parseInt(limitStr);
return new Pagination(`${pageNumber}`, `${limitStr}`, options.reversed);
}
}
export type TableRowInfo = {
@@ -79,6 +79,9 @@ export class DocumentsService {
defaultQuota: number = config.has("drive.defaultUserQuota")
? config.get("drive.defaultUserQuota")
: 0;
manageAccessEnabled: boolean = config.has("drive.featureManageAccess")
? config.get("drive.featureManageAccess")
: false;
logger: TdriveLogger = getLogger("Documents Service");
async init(): Promise<this> {
@@ -222,18 +225,12 @@ export class DocumentsService {
if (options?.sort) {
sortField = this.getSortFieldMapping(options.sort);
}
const dbType = await globalResolver.database.getConnector().getType();
// Initialize pagination
let pagination;
if (options?.pagination) {
const { page_token, limitStr } = options.pagination;
const pageNumber =
dbType === "mongodb" ? parseInt(page_token) : parseInt(page_token) / parseInt(limitStr);
pagination = new Pagination(`${pageNumber}`, `${limitStr}`, false);
}
if (options?.pagination)
pagination = globalResolver.database.getConnector().getOffsetPagination(options.pagination);
let children = isDirectory
? (
@@ -562,33 +559,38 @@ export class DocumentsService {
oldParent = item.parent_id;
}
if (key === "access_info") {
const sharedWith = content.access_info.entities.filter(
info =>
info.type === "user" &&
info.id !== context.user.id &&
!item.access_info.entities.find(entity => entity.id === info.id),
);
// if manage access is disabled, we don't allow changing access level
if (!this.manageAccessEnabled) {
delete content.access_info;
} else if (content.access_info) {
const sharedWith = content.access_info.entities.filter(
info =>
info.type === "user" &&
info.id !== context.user.id &&
!item.access_info.entities.find(entity => entity.id === info.id),
);
item.access_info = content.access_info;
item.access_info = content.access_info;
if (sharedWith.length > 0) {
// Notify the user that the document has been shared with them
this.logger.info("Notifying user that the document has been shared with them: ", {
sharedWith,
});
gr.services.documents.engine.notifyDocumentShared({
context,
item,
notificationEmitter: context.user.id,
notificationReceiver: sharedWith[0].id,
if (sharedWith.length > 0) {
// Notify the user that the document has been shared with them
this.logger.info("Notifying user that the document has been shared with them: ", {
sharedWith,
});
gr.services.documents.engine.notifyDocumentShared({
context,
item,
notificationEmitter: context.user.id,
notificationReceiver: sharedWith[0].id,
});
}
item.access_info.entities.forEach(info => {
if (!info.grantor) {
info.grantor = context.user.id;
}
});
}
item.access_info.entities.forEach(info => {
if (!info.grantor) {
info.grantor = context.user.id;
}
});
} else if (key === "name") {
renamedTo = item.name = await getItemName(
content.parent_id || item.parent_id,
@@ -1148,7 +1150,15 @@ export class DocumentsService {
}
if (file) {
const fileEntity = await globalResolver.services.files.save(null, file, options, context);
const fileEntity = await globalResolver.services.files.save(
null,
file,
{
...options,
filename: options.filename ?? driveFile.name,
},
context,
);
await globalResolver.services.documents.documents.createVersion(
driveFile.id,
@@ -1158,6 +1168,7 @@ export class DocumentsService {
file_metadata: {
external_id: fileEntity.id,
source: "internal",
name: file.filename ?? driveFile.name,
},
},
context,
@@ -1184,8 +1195,14 @@ export class DocumentsService {
)}`,
);
} catch (error) {
logger.error({ error: `${error}` }, "Failed to cancel editing Drive item");
CrudException.throwMe(error, new CrudException("Failed to cancel editing Drive item", 500));
logger.error(
{ error: `${error}` },
`Failed to ${keepEditing ? "update" : "end"} editing Drive item`,
);
CrudException.throwMe(
error,
new CrudException(`Failed to ${keepEditing ? "update" : "end"} editing Drive item`, 500),
);
}
}
};
@@ -344,6 +344,7 @@ export const addDriveItemToArchive = async (
if (item.is_in_trash) return;
if (!item.is_directory) {
// random comment
const file_id = item.last_version_cache.file_metadata.external_id;
const file = await gr.services.files.download(file_id, context);
@@ -24,6 +24,7 @@ import {
import { DriveFileDTO } from "../dto/drive-file-dto";
import { DriveFileDTOBuilder } from "../../services/drive-file-dto-builder";
import config from "config";
import { formatAttachmentContentDispositionHeader } from "../../../files/utils";
export class DocumentsController {
private driveFileDTOBuilder = new DriveFileDTOBuilder();
@@ -421,7 +422,7 @@ export class DocumentsController {
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
filename: q.resumableFilename || q.filename || file?.filename || undefined,
filename: q.filename || undefined,
type: q.resumableType || q.type || file?.mimetype || undefined,
waitForThumbnail: !!q.thumbnail_sync,
ignoreThumbnails: false,
@@ -510,9 +511,9 @@ export class DocumentsController {
return response;
} else if (archiveOrFile.file) {
const data = archiveOrFile.file;
const filename = encodeURIComponent(data.name.replace(/[^\p{L}0-9 _.-]/gu, ""));
response.header("Content-disposition", `attachment; filename="${filename}"`);
response.header("Content-Disposition", formatAttachmentContentDispositionHeader(data.name));
if (data.size) response.header("Content-Length", data.size);
response.type(data.mime);
return response.send(data.file);
@@ -565,7 +566,10 @@ export class DocumentsController {
try {
const archive = await globalResolver.services.documents.documents.createZip(ids, context);
reply.raw.setHeader("content-disposition", 'attachment; filename="twake_drive.zip"');
reply.raw.setHeader(
"content-disposition",
formatAttachmentContentDispositionHeader("twake_drive.zip"),
);
archive.on("finish", () => {
reply.status(200);
@@ -22,3 +22,12 @@ export const fileIsMedia = (file: Partial<File>): boolean => {
file.metadata?.mime?.startsWith("image/")
);
};
/**
* Generate RFC 5987 UTF-8 encoding compliant header value for `Content-Disposition`
* to make a browser download a reponse to a file with the provided name
*/
export const formatAttachmentContentDispositionHeader = (filename: string) => {
const encoded = encodeURIComponent(filename.replace(/[^\p{L}0-9 _.-]/gu, ""));
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
};
@@ -5,6 +5,7 @@ import { CompanyExecutionContext } from "../types";
import { UploadOptions } from "../../types";
import { PublicFile } from "../../entities/file";
import gr from "../../../global-resolver";
import { formatAttachmentContentDispositionHeader } from "../../utils";
export class FileController {
async save(
@@ -47,9 +48,8 @@ export class FileController {
const params = request.params;
try {
const data = await gr.services.files.download(params.id, context);
const filename = data.name.replace(/[^a-zA-Z0-9 -_.]/g, "");
response.header("Content-Disposition", formatAttachmentContentDispositionHeader(data.name));
response.header("Content-disposition", `attachment; filename="${filename}"`);
if (data.size) response.header("Content-Length", data.size);
response.type(data.mime);
return response.send(data.file);
@@ -2,5 +2,5 @@ import { Languages } from "./types";
export const languages: Languages = {
default: "en",
availables: ["en", "fr", "es", "vn", "ru"],
availables: ["en", "fr", "es", "vi", "ru"],
};
@@ -54,10 +54,6 @@ export function formatCompany(
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: true,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: true,
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]: true,
// use the config value for this one
[CompanyFeaturesEnum.COMPANY_SEARCH_USERS]: JSON.parse(
config.get("drive.featureSearchUsers") || "true",
),
[CompanyFeaturesEnum.COMPANY_SHARED_DRIVE]: JSON.parse(
config.get("drive.featureSharedDrive") || "true",
),
@@ -67,6 +63,9 @@ export function formatCompany(
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]: JSON.parse(
config.get("drive.featureUserQuota") || "false",
),
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: JSON.parse(
config.get("drive.featureManageAccess") || "true",
),
},
{
...(res.plan?.features || {}),
@@ -76,8 +75,6 @@ export function formatCompany(
},
);
console.log("🚀🚀 res.plan.features: ", res.plan.features);
return res;
}
@@ -91,10 +91,10 @@ export const companyObjectSchema = {
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: { type: "boolean" },
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_SEARCH_USERS]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_SHARED_DRIVE]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" },
guests: { type: "number" }, // to rename or delete
members: { type: "number" }, // to rename or delete
storage: { type: "number" }, // to rename or delete
@@ -83,10 +83,10 @@ export enum CompanyFeaturesEnum {
CHAT_EDIT_FILES = "chat:edit_files",
CHAT_UNLIMITED_STORAGE = "chat:unlimited_storage",
COMPANY_INVITE_MEMBER = "company:invite_member",
COMPANY_SEARCH_USERS = "company:search_users",
COMPANY_SHARED_DRIVE = "company:shared_drive",
COMPANY_DISPLAY_EMAIL = "company:display_email",
COMPANY_USER_QUOTA = "company:user_quota",
COMPANY_MANAGE_ACCESS = "company:managed_access",
}
export type CompanyFeaturesObject = {
@@ -96,10 +96,10 @@ export type CompanyFeaturesObject = {
[CompanyFeaturesEnum.CHAT_EDIT_FILES]?: boolean;
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]?: boolean;
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]?: boolean;
[CompanyFeaturesEnum.COMPANY_SEARCH_USERS]?: boolean;
[CompanyFeaturesEnum.COMPANY_SHARED_DRIVE]?: boolean;
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]?: boolean;
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean;
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean;
};
export type CompanyLimitsObject = {
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
import { init, TestPlatform } from "../setup";
import UserApi from "../common/user-api";
import config from "config";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
jest.mock("config");
describe("The Drive feature", () => {
let platform: TestPlatform;
let configHasSpy: jest.SpyInstance;
let configGetSpy: jest.SpyInstance;
beforeEach(async () => {
// Mocking config to disable manage access for the drive feature
configHasSpy = jest.spyOn(config, "has");
configGetSpy = jest.spyOn(config, "get");
configHasSpy.mockImplementation((setting: string) => {
return jest.requireActual("config").has(setting);
});
configGetSpy.mockImplementation((setting: string) => {
if (setting === "drive.featureManageAccess") {
return false; // Disable manage access
}
return jest.requireActual("config").get(setting);
});
// Initialize platform with required services
platform = await init({
services: [
"webserver",
"database",
"applications",
"search",
"storage",
"message-queue",
"user",
"search",
"files",
"messages",
"auth",
"channels",
"counter",
"statistics",
"platform-services",
"documents",
],
});
});
afterEach(async () => {
// Tear down platform after each test
await platform?.tearDown();
platform = null;
});
it("Shared with me should not contain files when manage access is off", async () => {
const oneUser = await UserApi.getInstance(platform, true, { companyRole: "admin" });
const anotherUser = await UserApi.getInstance(platform, true, { companyRole: "admin" });
// Upload files by the uploader user
const files = await oneUser.uploadAllFilesOneByOne();
// Wait for file processing
await new Promise(r => setTimeout(r, 5000));
// Share the file with recipient user
await anotherUser.shareWithPermissions(files[1], anotherUser.user.id, "read");
await new Promise(r => setTimeout(r, 3000)); // Wait for sharing process
// Check if the shared file appears in recipient's "shared with me" section
const sharedDocs = await anotherUser.browseDocuments("shared_with_me");
// Validate that there are no shared files due to manage access being off
expect(sharedDocs.children.length).toBe(0);
});
});