From 00f819fb6d3214e1fd35f56194963089f4540dab Mon Sep 17 00:00:00 2001 From: Montassar Ghanmy Date: Tue, 19 Nov 2024 17:39:18 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9E=20Add=20antivirus=20inside=20Twake?= =?UTF-8?q?=20Drive=20(#725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐞 Add antivirus inside Twake Drive (#725) --- .github/workflows/build.yml | 2 +- .../config/custom-environment-variables.json | 10 +- tdrive/backend/node/config/default.json | 10 +- tdrive/backend/node/package-lock.json | 9 + tdrive/backend/node/package.json | 1 + .../notification-document-av-scan-alert.eta | 48 +++ ...ication-document-av-scan-alert.subject.eta | 1 + .../notification-document-av-scan-alert.eta | 48 +++ ...ication-document-av-scan-alert.subject.eta | 1 + tdrive/backend/node/src/services/av/index.ts | 15 + .../src/services/av/service/av-exception.ts | 22 ++ .../node/src/services/av/service/index.ts | 98 ++++++ .../services/documents/entities/drive-file.ts | 17 + .../documents/services/engine/index.ts | 25 +- .../src/services/documents/services/index.ts | 286 ++++++++++++++++- .../node/src/services/documents/types.ts | 7 + .../node/src/services/documents/utils.ts | 1 + .../documents/web/controllers/documents.ts | 57 ++++ .../node/src/services/documents/web/routes.ts | 14 + .../src/services/documents/web/schemas.ts | 1 + .../node/src/services/files/services/index.ts | 123 ++++---- .../node/src/services/global-resolver.ts | 7 + .../backend/node/src/services/user/utils.ts | 3 + .../node/src/services/user/web/schemas.ts | 1 + .../node/src/services/user/web/types.ts | 2 + tdrive/backend/node/src/utils/get-config.ts | 5 + tdrive/backend/node/test/e2e/av/av.spec.ts | 131 ++++++++ .../node/test/e2e/av/config/runtime.json | 12 + .../node/test/e2e/av/load_test_config.ts | 9 + .../test/e2e/common/entities/mock_entities.ts | 1 + .../backend/node/test/e2e/common/user-api.ts | 291 ++++++++++-------- .../documents-pagination-sorting.spec.ts | 3 + tdrive/backend/node/yarn.lock | 5 + tdrive/docker-compose.dev.mongo.yml | 8 + .../docker-compose.dev.tests.opensearch.yml | 21 +- tdrive/docker-compose.tests.postgresql.yml | 1 - tdrive/docker-compose.tests.yml | 13 + tdrive/frontend/public/locales/en.json | 7 + tdrive/frontend/public/locales/fr.json | 7 + tdrive/frontend/public/locales/ru.json | 7 + tdrive/frontend/public/locales/vi.json | 7 + .../features/drive/api-client/api-client.ts | 14 + .../drive/hooks/use-drive-actions.tsx | 122 +++++++- .../frontend/src/app/features/drive/types.ts | 1 + .../services/feature-toggles-service.ts | 2 + .../views/client/body/drive/context-menu.tsx | 68 ++-- .../body/drive/documents/document-row.tsx | 33 +- .../body/drive/modals/versions/index.tsx | 19 +- .../src/app/views/error/error-boundary.tsx | 2 +- 49 files changed, 1341 insertions(+), 257 deletions(-) create mode 100644 tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.eta create mode 100644 tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.subject.eta create mode 100644 tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.eta create mode 100644 tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.subject.eta create mode 100644 tdrive/backend/node/src/services/av/index.ts create mode 100644 tdrive/backend/node/src/services/av/service/av-exception.ts create mode 100644 tdrive/backend/node/src/services/av/service/index.ts create mode 100644 tdrive/backend/node/src/utils/get-config.ts create mode 100644 tdrive/backend/node/test/e2e/av/av.spec.ts create mode 100644 tdrive/backend/node/test/e2e/av/config/runtime.json create mode 100644 tdrive/backend/node/test/e2e/av/load_test_config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1036eb39..7b42a6a2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,7 +67,7 @@ jobs: - name: e2e-opensearch-test run: | cd tdrive - docker compose -f docker-compose.dev.tests.opensearch.yml up -d --force-recreate opensearch-node1 postgres node + docker compose -f docker-compose.dev.tests.opensearch.yml up -d --force-recreate opensearch-node1 postgres node av sleep 60 docker compose -f docker-compose.dev.tests.opensearch.yml logs docker compose -f docker-compose.dev.tests.opensearch.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=opensearch -e DB_DRIVER=postgres -e PUBSUB_TYPE=local node npm run test:all diff --git a/tdrive/backend/node/config/custom-environment-variables.json b/tdrive/backend/node/config/custom-environment-variables.json index 766fdabf7..3e337b868 100644 --- a/tdrive/backend/node/config/custom-environment-variables.json +++ b/tdrive/backend/node/config/custom-environment-variables.json @@ -135,6 +135,14 @@ "defaultUserQuota": "DRIVE_DEFAULT_USER_QUOTA", "featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL", "featureUserQuota": "ENABLE_FEATURE_USER_QUOTA", - "featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS" + "featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS", + "featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS" + }, + "av": { + "host": "AV_HOST", + "port": "AV_PORT", + "debugMode": "AV_DEBUG_MODE", + "timeout": "AV_TIMEOUT", + "maxFileSize": "AV_MAX_FILE_SIZE" } } diff --git a/tdrive/backend/node/config/default.json b/tdrive/backend/node/config/default.json index 2d72b797e..30da3a6f7 100644 --- a/tdrive/backend/node/config/default.json +++ b/tdrive/backend/node/config/default.json @@ -119,7 +119,15 @@ "featureUserQuota": false, "featureManageAccess": true, "defaultCompany": "00000000-0000-4000-0000-000000000000", - "defaultUserQuota": 200000000 + "defaultUserQuota": 200000000, + "featureAntivirus": false + }, + "av": { + "host": "av", + "port": 3310, + "debugMode": false, + "timeout": 2000, + "maxFileSize": 26214400 }, "applications": { "grid": [ diff --git a/tdrive/backend/node/package-lock.json b/tdrive/backend/node/package-lock.json index 9b3db99d1..02f6ad54b 100644 --- a/tdrive/backend/node/package-lock.json +++ b/tdrive/backend/node/package-lock.json @@ -34,6 +34,7 @@ "archiver": "^5.3.1", "axios": "^1.6.8", "bcrypt": "^5.0.1", + "clamscan": "^2.4.0", "class-transformer": "^0.3.1", "cli-table": "^0.3.6", "config": "^3.3.2", @@ -4473,6 +4474,14 @@ "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", "dev": true }, + "node_modules/clamscan": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz", + "integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/class-transformer": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz", diff --git a/tdrive/backend/node/package.json b/tdrive/backend/node/package.json index a241213b9..6bc3eb2b5 100644 --- a/tdrive/backend/node/package.json +++ b/tdrive/backend/node/package.json @@ -131,6 +131,7 @@ "archiver": "^5.3.1", "axios": "^1.6.8", "bcrypt": "^5.0.1", + "clamscan": "^2.4.0", "class-transformer": "^0.3.1", "cli-table": "^0.3.6", "config": "^3.3.2", diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.eta new file mode 100644 index 000000000..0338a6121 --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.eta @@ -0,0 +1,48 @@ +<% layout('./_structure') %> +<% it.title = 'Twake Drive Antivirus Alert' %> + +<%~ includeFile("../common/_body.eta", { + paragraphs: [ + ` + + + + + + +
+
+ Important: Antivirus Alert on Your Twake Drive +
+
+ `, + ` + + + + + + +
+
+ Antivirus scan for a file on your Twake Drive has flagged an issue. +
+
+ + File: ${it.notifications[0].item.name} + +
+
+ + Issue: ${ it.notifications[0].item.av_status === "scan_failed" ? "Scan Failed 🔍" : it.notifications[0].item.av_status === "malicious" ? "Malicious Content Detected ⚠️" : "File too large to be scanned 🚫" } + +
+
+
+ + View File Details + +
+ ` + ] +}) %> diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.subject.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.subject.eta new file mode 100644 index 000000000..9549bc30b --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-document-av-scan-alert.subject.eta @@ -0,0 +1 @@ +Twake Drive Antivirus Alert \ No newline at end of file diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.eta new file mode 100644 index 000000000..6b4e6fd45 --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.eta @@ -0,0 +1,48 @@ +<% layout('./_structure') %> +<% it.title = 'Alerte Antivirus dans votre Twake Drive' %> + +<%~ includeFile("../common/_body.eta", { + paragraphs: [ + ` + + + + + + +
+
+ Important: Alerte Antivirus dans votre Twake Drive +
+
+ `, + ` + + + + + + +
+
+ L'analyse antivirus d'un fichier dans votre Twake Drive a signalé un problème. +
+
+ + Fichier: ${it.notifications[0].item.name} + +
+
+ + Problème: ${ it.notifications[0].item.av_status === "scan_failed" ? "Échec de l'analyse 🔍" : it.notifications[0].item.av_status === "malicious" ? "Contenu malveillant détecté ⚠️" : "Fichier trop volumineux pour être analysé 🚫" } + +
+
+
+ + Voir sur Twake Drive + +
+ ` + ] +}) %> diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.subject.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.subject.eta new file mode 100644 index 000000000..9ff7e6e0e --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-document-av-scan-alert.subject.eta @@ -0,0 +1 @@ +Alerte Antivirus dans votre Twake Drive \ No newline at end of file diff --git a/tdrive/backend/node/src/services/av/index.ts b/tdrive/backend/node/src/services/av/index.ts new file mode 100644 index 000000000..7642a7243 --- /dev/null +++ b/tdrive/backend/node/src/services/av/index.ts @@ -0,0 +1,15 @@ +import { TdriveService } from "../../core/platform/framework"; + +export default class AVService extends TdriveService { + version = "1"; + name = "antivirus"; + + public async doInit(): Promise { + return this; + } + + // TODO: remove + api(): undefined { + return undefined; + } +} diff --git a/tdrive/backend/node/src/services/av/service/av-exception.ts b/tdrive/backend/node/src/services/av/service/av-exception.ts new file mode 100644 index 000000000..614971b8c --- /dev/null +++ b/tdrive/backend/node/src/services/av/service/av-exception.ts @@ -0,0 +1,22 @@ +export class AVException extends Error { + constructor(readonly details: string, readonly status: number) { + super(); + this.message = details; + } + + static initializationFailed(details: string): AVException { + return new AVException(details, 503); + } + + static fileNotFound(details: string): AVException { + return new AVException(details, 404); + } + + static scanFailed(details: string): AVException { + return new AVException(details, 500); + } + + static handleError(cause: Error, newException: AVException): void { + throw cause instanceof AVException ? cause : newException; + } +} diff --git a/tdrive/backend/node/src/services/av/service/index.ts b/tdrive/backend/node/src/services/av/service/index.ts new file mode 100644 index 000000000..8774d891e --- /dev/null +++ b/tdrive/backend/node/src/services/av/service/index.ts @@ -0,0 +1,98 @@ +import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework"; +import { getLogger, logger, TdriveLogger } from "../../../core/platform/framework"; +import NodeClam from "clamscan"; +import { AVStatus, DriveFile } from "src/services/documents/entities/drive-file"; +import { FileVersion } from "src/services/documents/entities/file-version"; +import { DriveExecutionContext } from "src/services/documents/types"; +import globalResolver from "../../../services/global-resolver"; +import { getFilePath } from "../../files/services"; +import { getConfigOrDefault } from "../../../utils/get-config"; +import { AVException } from "./av-exception"; + +export class AVServiceImpl implements TdriveServiceProvider, Initializable { + version: "1"; + av: NodeClam = null; + logger: TdriveLogger = getLogger("Antivirus Service"); + avEnabled = getConfigOrDefault("drive.featureAntivirus", false); + private MAX_FILE_SIZE = getConfigOrDefault("av.maxFileSize", 26214400); // 25 MB + + async init(): Promise { + try { + if (this.avEnabled) { + this.av = await new NodeClam().init({ + removeInfected: false, // Do not remove infected files + quarantineInfected: false, // Do not quarantine, just alert + scanLog: null, // No log file for this test + debugMode: getConfigOrDefault("av.debugMode", false), // Enable debug messages + clamdscan: { + host: getConfigOrDefault("av.host", "localhost"), // IP of the server + port: getConfigOrDefault("av.port", 3310) as number, // ClamAV server port + timeout: getConfigOrDefault("av.timeout", 2000), // Timeout for scans + localFallback: true, // Use local clamscan if needed + }, + }); + } + } catch (error) { + logger.error({ error: `${error}` }, "Error while initializing Antivirus Service"); + throw AVException.initializationFailed("Failed to initialize Antivirus service"); + } + return this; + } + + async scanDocument( + item: Partial, + version: Partial, + onScanComplete: (status: AVStatus) => Promise, + context: DriveExecutionContext, + ): Promise { + try { + // get the file from the storage + const file = await globalResolver.services.files.get( + version.file_metadata.external_id, + context, + ); + + if (!file) { + this.logger.error(`File ${version.file_metadata.external_id} not found`); + throw AVException.fileNotFound(`File ${version.file_metadata.external_id} not found`); + } + // check if the file is too large + if (file.upload_data.size > this.MAX_FILE_SIZE) { + this.logger.info( + `File ${file.id} is too large (${file.upload_data.size} bytes) to be scanned. Skipping...`, + ); + return "skipped"; + } + + // read the file from the storage + const readableStream = await globalResolver.platformServices.storage.read(getFilePath(file), { + totalChunks: file.upload_data.chunks, + encryptionAlgo: globalResolver.services.files.getEncryptionAlgorithm(), + encryptionKey: file.encryption_key, + }); + + // scan the file + this.av.scanStream(readableStream, async (err, { isInfected, viruses }) => { + if (err) { + await onScanComplete("scan_failed"); + this.logger.error(`Scan failed for item ${item.id} due to error: ${err.message}`); + } else if (isInfected) { + await onScanComplete("malicious"); + this.logger.info(`Item ${item.id} is malicious. Viruses found: ${viruses.join(", ")}`); + } else { + await onScanComplete("safe"); + this.logger.info(`Item ${item.id} is safe with no viruses detected.`); + } + }); + + return "scanning"; + } catch (error) { + // mark the file as failed to scan + await onScanComplete("scan_failed"); + + // log the error + this.logger.error(`Error scanning file ${item.last_version_cache.file_metadata.external_id}`); + throw AVException.scanFailed("Document scanning encountered an error"); + } + } +} diff --git a/tdrive/backend/node/src/services/documents/entities/drive-file.ts b/tdrive/backend/node/src/services/documents/entities/drive-file.ts index 5a793abc6..2676681b2 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.ts @@ -8,6 +8,10 @@ import * as UUIDTools from "../../../utils/uuid"; export const TYPE = "drive_files"; export type DriveScope = "personal" | "shared"; +export type AVStatusSafe = "safe"; +export type AVStatusUnsafe = "uploaded" | "scanning" | "scan_failed" | "malicious" | "skipped"; +export type AVStatus = AVStatusSafe | AVStatusUnsafe; + /** * This represents an item in the file hierarchy. * @@ -32,6 +36,15 @@ export type DriveScope = "personal" | "shared"; * if `scope == "personal"`, otherwise the trash of the shared drive * - `"trash_$userid"`: Trash folder for a given user (same note as `"user_$userid"`) * - `"shared_with_me"`: for the feature of the same name + * + * The `status` field represents the current scan status of the file, + * which can be one of the following: + * - `"uploaded"`: The file has been uploaded but not yet scanned. + * - `"scanning"`: The file is currently being scanned. + * - `"scan_failed"`: The scan failed, possibly due to an error. + * - `"safe"`: The file has been scanned and marked as safe. + * - `"malicious"`: The file has been marked as potentially malicious. + * - `"skipped"`: The file scan was skipped (file size too big). */ @Entity(TYPE, { globalIndexes: [ @@ -122,6 +135,10 @@ export class DriveFile { @Type(() => String) @Column("scope", "string") scope: DriveScope; + + @Type(() => String) + @Column("av_status", "string") + av_status: AVStatus; } const OnlyOfficeSafeDocKeyBase64 = { diff --git a/tdrive/backend/node/src/services/documents/services/engine/index.ts b/tdrive/backend/node/src/services/documents/services/engine/index.ts index 930643a1e..a0b17933b 100644 --- a/tdrive/backend/node/src/services/documents/services/engine/index.ts +++ b/tdrive/backend/node/src/services/documents/services/engine/index.ts @@ -2,7 +2,7 @@ import globalResolver from "../../../global-resolver"; import { logger } from "../../../../core/platform/framework"; import { localEventBus } from "../../../../core/platform/framework/event-bus"; import { Initializable } from "../../../../core/platform/framework"; -import { DocumentEvents, NotificationPayloadType } from "../../types"; +import { DocumentEvents, NotificationPayloadType, eventToTemplateMap } from "../../types"; import { DocumentsProcessor } from "./extract-keywords"; import Repository from "../../../../core/platform/services/database/services/orm/repository/repository"; import { DriveFile, TYPE } from "../../entities/drive-file"; @@ -17,10 +17,14 @@ export class DocumentsEngine implements Initializable { id: e.context.company.id, }); const language = receiver.preferences?.language || "en"; - const emailTemplate = - event === DocumentEvents.DOCUMENT_SAHRED - ? "notification-document-shared" - : "notification-document-version-updated"; + + const emailTemplate = eventToTemplateMap[event]; + + if (!emailTemplate) { + logger.error(`Error dispatching document event. Unknown event type: ${event}`); + return; // Early return on unknown event type + } + try { const { html, text, subject } = await globalResolver.platformServices.emailPusher.build( emailTemplate, @@ -67,6 +71,13 @@ export class DocumentsEngine implements Initializable { }, ); + localEventBus.subscribe( + DocumentEvents.DOCUMENT_AV_SCAN_ALERT, + async (e: NotificationPayloadType) => { + await this.DispatchDocumentEvent(e, DocumentEvents.DOCUMENT_AV_SCAN_ALERT); + }, + ); + return this; } @@ -77,4 +88,8 @@ export class DocumentsEngine implements Initializable { notifyDocumentVersionUpdated(notificationPayload: NotificationPayloadType) { localEventBus.publish(DocumentEvents.DOCUMENT_VERSION_UPDATED, notificationPayload); } + + notifyDocumentAVScanAlert(notificationPayload: NotificationPayloadType) { + localEventBus.publish(DocumentEvents.DOCUMENT_AV_SCAN_ALERT, notificationPayload); + } } diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index 8f3b840db..221a32333 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -14,7 +14,7 @@ import { PublicFile } from "../../../services/files/entities/file"; import globalResolver from "../../../services/global-resolver"; import { hasCompanyAdminLevel } from "../../../utils/company"; import gr from "../../global-resolver"; -import { DriveFile, EditingSessionKeyFormat, TYPE } from "../entities/drive-file"; +import { AVStatus, DriveFile, EditingSessionKeyFormat, TYPE } from "../entities/drive-file"; import { FileVersion, TYPE as FileVersionType } from "../entities/file-version"; import User, { TYPE as UserType } from "../../user/entities/user"; @@ -49,6 +49,7 @@ import { updateItemSize, isInTrash, } from "../utils"; +import { getConfigOrDefault } from "../../../utils/get-config"; import { checkAccess, getAccessLevel, @@ -58,7 +59,6 @@ import { } from "./access-check"; import archiver from "archiver"; import internal from "stream"; -import config from "config"; import { MultipartFile } from "@fastify/multipart"; import { UploadOptions } from "src/services/files/types"; import { SortType } from "src/core/platform/services/search/api"; @@ -73,15 +73,9 @@ export class DocumentsService { userRepository: Repository; ROOT: RootType = "root"; TRASH: TrashType = "trash"; - quotaEnabled: boolean = config.has("drive.featureUserQuota") - ? config.get("drive.featureUserQuota") - : false; - defaultQuota: number = config.has("drive.defaultUserQuota") - ? config.get("drive.defaultUserQuota") - : 0; - manageAccessEnabled: boolean = config.has("drive.featureManageAccess") - ? config.get("drive.featureManageAccess") - : false; + quotaEnabled: boolean = getConfigOrDefault("drive.featureUserQuota", false); + defaultQuota: number = getConfigOrDefault("drive.defaultUserQuota", 0); + manageAccessEnabled: boolean = getConfigOrDefault("drive.featureManageAccess", false); logger: TdriveLogger = getLogger("Documents Service"); async init(): Promise { @@ -391,9 +385,8 @@ export class DocumentsService { throw Error("User does not have access to this item parent"); } + let fileToProcess; if (file || driveItem.is_directory === false) { - let fileToProcess; - if (file) { fileToProcess = file; } else if (driveItemVersion.file_metadata.external_id) { @@ -484,6 +477,45 @@ export class DocumentsService { //TODO[ASH] update item size only for files, there is not need to do during direcotry creation await updateItemSize(driveItem.parent_id, this.repository, context); + // If AV feature is enabled, scan the file + if (globalResolver.services.av?.avEnabled && version) { + try { + driveItem.av_status = await globalResolver.services.av.scanDocument( + driveItem, + driveItemVersion, + async (av_status: AVStatus) => { + // Update the AV status of the file + await this.handleAVStatusUpdate(driveItem, av_status, context); + // Handle preview generation + if (av_status === "safe" && fileToProcess) { + const file = await globalResolver.services.files.generatePreview( + fileToProcess, + { + waitForThumbnail: true, + ignoreThumbnails: false, + }, + context, + ); + if (file) { + driveItemVersion.file_metadata.thumbnails = file?.thumbnails; + await this.fileVersionRepository.save(driveItemVersion); + driveItem.last_version_cache = driveItemVersion; + } + } + }, + context, + ); + await this.repository.save(driveItem); + if (driveItem.av_status === "skipped") { + // Notify the user that the document has been skipped + await this.notifyAVScanAlert(driveItem, context); + } + } catch (error) { + this.logger.error(`Error scanning file ${driveItemVersion.file_metadata.external_id}`); + CrudException.throwMe(error, new CrudException("Failed to scan file", 500)); + } + } + await globalResolver.platformServices.messageQueue.publish( "services:documents:process", { @@ -545,7 +577,13 @@ export class DocumentsService { throw Error("content mismatch"); } - const updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"]; + let updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"]; + + // Check if AV feature is enabled and file is malicious + if (globalResolver.services.av?.avEnabled && item.av_status === "malicious") { + updatable = ["is_in_trash"]; + } + let renamedTo: string | undefined; for (const key of updatable) { if ((content as any)[key]) { @@ -864,6 +902,12 @@ export class DocumentsService { throw new CrudException("User does not have access to this item or its children", 401); } + // Check if AV feature is enabled and file is malicious + if (globalResolver.services.av?.avEnabled && item.av_status === "malicious") { + this.logger.error("Cannot update a malicious file"); + throw new CrudException("Cannot update a malicious file", 403); + } + if (await isInTrash(item, this.repository, context)) { if (item.is_in_trash != true) { if (item.scope === "personal") { @@ -921,6 +965,13 @@ export class DocumentsService { } const driveItemVersion = getDefaultDriveItemVersion(version, context); + const fileToProcess = await globalResolver.services.files.getFile( + { + id: driveItemVersion.file_metadata.external_id, + company_id: context.company.id, + }, + context, + ); const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context); // if quota is enabled, check if the user has enough space @@ -960,6 +1011,45 @@ export class DocumentsService { await updateItemSize(item.parent_id, this.repository, context); + // If AV feature is enabled, scan the file + if (globalResolver.services.av?.avEnabled && version) { + try { + item.av_status = await globalResolver.services.av.scanDocument( + item, + driveItemVersion, + async (av_status: AVStatus) => { + // Update the AV status of the file + await this.handleAVStatusUpdate(item, av_status, context); + // Handle preview generation + if (av_status === "safe" && fileToProcess) { + const file = await globalResolver.services.files.generatePreview( + fileToProcess, + { + waitForThumbnail: true, + ignoreThumbnails: false, + }, + context, + ); + if (file) { + driveItemVersion.file_metadata.thumbnails = file?.thumbnails; + await this.fileVersionRepository.save(driveItemVersion); + item.last_version_cache = driveItemVersion; + } + } + }, + context, + ); + await this.repository.save(item); + if (item.av_status === "skipped") { + // Notify the user that the document has been skipped + await this.notifyAVScanAlert(item, context); + } + } catch (error) { + this.logger.error(`Error scanning file ${driveItemVersion.file_metadata.external_id}`); + CrudException.throwMe(error, new CrudException("Failed to scan file", 500)); + } + } + await globalResolver.platformServices.messageQueue.publish( "services:documents:process", { @@ -978,6 +1068,150 @@ export class DocumentsService { } }; + /** + * Checks if directory contains malicious files + * + * @param {string} id - the dir id to check. + * @param {DriveExecutionContext} context - the company execution context + * @returns {Promise} - the check result + */ + containsMaliciousFiles = async (id: string, context: DriveExecutionContext): Promise => { + if (!context) { + this.logger.error("Invalid execution context"); + return null; + } + + try { + // Check user access + const hasAccess = await checkAccess(id, null, "read", this.repository, context); + if (!hasAccess) { + this.logger.error("User does not have access to drive item", id); + throw new Error("User does not have access to this item"); + } + + // Retrieve the item + const item = await this.repository.findOne( + { company_id: context.company.id, id }, + {}, + context, + ); + + if (!item) { + throw new Error("Drive item not found"); + } + + if (!item.is_directory) { + throw new Error("Cannot check malicious files for a file"); + } + + // Retrieve children + const children = await this.repository.find( + { company_id: context.company.id, parent_id: id }, + {}, + context, + ); + + const entities = children.getEntities(); + + // Check files in the current directory + const maliciousFiles = entities.filter( + child => !child.is_directory && child.av_status !== "safe", + ); + + if (maliciousFiles.length > 0) { + return true; + } + + // Recursively check subdirectories + const subdirectories = entities.filter(child => child.is_directory); + for (const subdirectory of subdirectories) { + const hasMaliciousFiles = await this.containsMaliciousFiles(subdirectory.id, context); + if (hasMaliciousFiles) { + return true; + } + } + + return false; + } catch (error) { + this.logger.error({ error: `${error}` }, "Failed to check malicious files"); + CrudException.throwMe(error, new CrudException("Failed to check malicious files", 500)); + } + }; + + /** + * Triggers an AV Rescan for the document. + * + * @param {string} id - the Drive item id to rescan. + * @param {DriveExecutionContext} context - the company execution context + * @returns {Promise} - the DriveFile after the rescan has been triggered + */ + rescan = async (id: string, context: DriveExecutionContext): Promise => { + if (!context) { + this.logger.error("Invalid execution context"); + throw new Error("Execution context is required"); // Explicit error to indicate a fatal issue + } + + try { + const hasAccess = await checkAccess(id, null, "write", this.repository, context); + if (!hasAccess) { + this.logger.warn(`Access denied for user to drive item ${id}`); + throw new Error("User does not have access to this item"); + } + + const item = await this.repository.findOne( + { + id, + company_id: context.company.id, + }, + {}, + context, + ); + + if (!item) { + this.logger.warn(`Drive item ${id} not found`); + throw new Error("Drive item not found"); + } + + if (item.is_directory) { + this.logger.warn(`Attempted to rescan a directory ${id}`); + throw new Error("Cannot rescan a directory"); + } + + if (globalResolver.services.av?.avEnabled) { + try { + item.av_status = await globalResolver.services.av.scanDocument( + item, + item.last_version_cache, + async (av_status: AVStatus) => { + await this.handleAVStatusUpdate(item, av_status, context); + }, + context, + ); + + await this.repository.save(item); + + if (item.av_status === "skipped") { + this.logger.info(`AV scan skipped for file ${item.id}`); + await this.notifyAVScanAlert(item, context); + } + } catch (scanError) { + this.logger.error( + `Error scanning file ${item.last_version_cache.file_metadata.external_id}: ${scanError.message}`, + ); + throw new CrudException("Error scanning file", 500); + } + } else { + this.logger.error("AV scanning is not enabled"); + throw new Error("An unexpected error occurred. Please try again later."); + } + + return item; + } catch (error) { + this.logger.error({ error: `${error}` }, `Failed to rescan drive item ${id}`); + throw new CrudException("Failed to rescan the drive item", 500); + } + }; + /** * If not already in an editing session, uses the `editing_session_key` of the * `DriveFile` entity to store a unique new value to expect an update later @@ -1521,4 +1755,28 @@ export class DocumentsService { sortField[sortFieldMapping[sort?.by] || "last_modified"] = sort?.order || "desc"; return sortField; }; + + // Helper function to notify user about AV scan alert + notifyAVScanAlert = async (item: DriveFile, context: DriveExecutionContext) => { + await gr.services.documents.engine.notifyDocumentAVScanAlert({ + context, + item, + notificationEmitter: context.user.id, + notificationReceiver: context.user.id, + }); + }; + + // Helper function to update AV status and save the drive item + handleAVStatusUpdate = async ( + item: DriveFile, + status: AVStatus, + context: DriveExecutionContext, + ) => { + item.av_status = status; + await this.repository.save(item); + + if (["malicious", "scan_failed"].includes(status)) { + await this.notifyAVScanAlert(item, context); + } + }; } diff --git a/tdrive/backend/node/src/services/documents/types.ts b/tdrive/backend/node/src/services/documents/types.ts index fc38bbfac..740843566 100644 --- a/tdrive/backend/node/src/services/documents/types.ts +++ b/tdrive/backend/node/src/services/documents/types.ts @@ -116,8 +116,15 @@ export type DriveTdriveTab = { export enum DocumentEvents { DOCUMENT_SAHRED = "document_shared", DOCUMENT_VERSION_UPDATED = "document_version_updated", + DOCUMENT_AV_SCAN_ALERT = "document_av_scan_alert", } +export const eventToTemplateMap: Record = { + [DocumentEvents.DOCUMENT_AV_SCAN_ALERT]: "notification-document-av-scan-alert", + [DocumentEvents.DOCUMENT_VERSION_UPDATED]: "notification-document-version-updated", + [DocumentEvents.DOCUMENT_SAHRED]: "notification-document-shared", +}; + export type NotificationPayloadType = { context: CompanyExecutionContext; item: DriveFile; diff --git a/tdrive/backend/node/src/services/documents/utils.ts b/tdrive/backend/node/src/services/documents/utils.ts index a503e9efe..8e5963f65 100644 --- a/tdrive/backend/node/src/services/documents/utils.ts +++ b/tdrive/backend/node/src/services/documents/utils.ts @@ -82,6 +82,7 @@ export const getDefaultDriveItem = ( parent_id: item.parent_id || "root", content_keywords: item.content_keywords || "", scope: "personal", + av_status: "uploaded", description: item.description || "", access_info: item.access_info || { entities: [ diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts index 348971fa5..145706aa8 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -335,6 +335,63 @@ export class DocumentsController { } }; + /** + * Checks if directory contains malicious files + * + * @param {FastifyRequest} request + * @returns {Promise} + */ + containsMaliciousFiles = async ( + request: FastifyRequest<{ + Params: ItemRequestParams; + Querystring: { public_token?: string }; + }>, + ): Promise => { + try { + const context = getDriveExecutionContext(request); + const { id } = request.params; + + if (!id) throw new CrudException("Missing id", 400); + + return await globalResolver.services.documents.documents.containsMaliciousFiles(id, context); + } catch (error) { + logger.error({ error: `${error}` }, "Failed to check for malicious files in Drive item"); + CrudException.throwMe( + error, + new CrudException("Failed to check for malicious files in Drive item", 500), + ); + } + }; + + /** + * Triggers an AV Rescan for the document. + * + * @param {FastifyRequest} request + * @returns {Promise} + */ + rescan = async ( + request: FastifyRequest<{ + Params: ItemRequestParams; + Body: Partial; + Querystring: { public_token?: string }; + }>, + ): Promise => { + try { + const context = getDriveExecutionContext(request); + const { id } = request.params; + + if (!id) throw new CrudException("Missing id", 400); + + return await globalResolver.services.documents.documents.rescan(id, context); + } catch (error) { + logger.error({ error: `${error}` }, "Failed to trigger AV rescan for Drive item"); + CrudException.throwMe( + error, + new CrudException("Failed to trigger AV rescan for Drive item", 500), + ); + } + }; + /** * Begin an editing session if none exists, or return the existing one * @returns The `editing_session_key` that was either set or already was there diff --git a/tdrive/backend/node/src/services/documents/web/routes.ts b/tdrive/backend/node/src/services/documents/web/routes.ts index 0b58856f5..ad08e8fec 100644 --- a/tdrive/backend/node/src/services/documents/web/routes.ts +++ b/tdrive/backend/node/src/services/documents/web/routes.ts @@ -82,6 +82,20 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) handler: documentsController.beginEditing.bind(documentsController), }); + fastify.route({ + method: "POST", + url: `${serviceUrl}/:id/check_malware`, + preValidation: [fastify.authenticateOptional], + handler: documentsController.containsMaliciousFiles.bind(documentsController), + }); + + fastify.route({ + method: "POST", + url: `${serviceUrl}/:id/rescan`, + preValidation: [fastify.authenticateOptional], + handler: documentsController.rescan.bind(documentsController), + }); + fastify.route({ method: "GET", url: editingSessionBase, //TODO NONONO check authenticate*Optional* diff --git a/tdrive/backend/node/src/services/documents/web/schemas.ts b/tdrive/backend/node/src/services/documents/web/schemas.ts index 65384de78..3703ea459 100644 --- a/tdrive/backend/node/src/services/documents/web/schemas.ts +++ b/tdrive/backend/node/src/services/documents/web/schemas.ts @@ -84,6 +84,7 @@ const documentSchema = { attachements: { type: "array" }, last_version_cache: fileVersionSchema, scope: { type: "string" }, + av_status: { type: "string" }, }, }; diff --git a/tdrive/backend/node/src/services/files/services/index.ts b/tdrive/backend/node/src/services/files/services/index.ts index 4ade5c6fc..233d1dd8b 100644 --- a/tdrive/backend/node/src/services/files/services/index.ts +++ b/tdrive/backend/node/src/services/files/services/index.ts @@ -149,59 +149,8 @@ export class FileServiceImpl { entity.upload_data.size = totalUploadedSize; await this.repository.save(entity, context); - /** Send preview generation task */ - if (entity.upload_data.size < this.max_preview_file_size) { - const document: PreviewMessageQueueRequest["document"] = { - id: JSON.stringify(_.pick(entity, "id", "company_id")), - provider: gr.platformServices.storage.getConnectorType(), - - path: getFilePath(entity), - encryption_algo: this.algorithm, - encryption_key: entity.encryption_key, - chunks: entity.upload_data.chunks, - - filename: entity.metadata.name, - mime: entity.metadata.mime, - }; - const output = { - provider: gr.platformServices.storage.getConnectorType(), - path: `${getFilePath(entity)}/thumbnails/`, - encryption_algo: this.algorithm, - encryption_key: entity.encryption_key, - pages: 10, - }; - - entity.metadata.thumbnails_status = "waiting"; - await this.repository.save(entity, context); - - if (!options?.ignoreThumbnails) { - try { - await gr.platformServices.messageQueue.publish( - "services:preview", - { - data: { document, output }, - }, - ); - - if (options.waitForThumbnail) { - entity = await gr.services.files.getFile( - { - id: entity.id, - company_id: context.company.id, - }, - context, - { waitForThumbnail: true }, - ); - } - } catch (err) { - entity.metadata.thumbnails_status = "error"; - await this.repository.save(entity, context); - - logger.warn({ err }, "Previewing - Error while sending "); - } - } - } - /** End preview generation task generation */ + /** Send preview generation task if av is not enabled */ + if (!gr.services.av?.avEnabled) await this.generatePreview(entity, options, context); } } @@ -268,6 +217,70 @@ export class FileServiceImpl { }; } + generatePreview = async ( + entity: File, + options: { waitForThumbnail?: boolean; ignoreThumbnails?: boolean }, + context: CompanyExecutionContext, + ) => { + if (entity.upload_data.size < this.max_preview_file_size) { + const { document, output } = this.previewPayload(entity); + + entity.metadata.thumbnails_status = "waiting"; + await this.repository.save(entity, context); + + if (!options?.ignoreThumbnails) { + try { + await gr.platformServices.messageQueue.publish( + "services:preview", + { + data: { document, output }, + }, + ); + + if (options.waitForThumbnail) { + entity = await gr.services.files.getFile( + { + id: entity.id, + company_id: context.company.id, + }, + context, + { waitForThumbnail: true }, + ); + return entity; + } + } catch (err) { + entity.metadata.thumbnails_status = "error"; + await this.repository.save(entity, context); + + logger.warn({ err }, "Previewing - Error while sending "); + } + } + } + }; + + previewPayload(entity: File) { + const document: PreviewMessageQueueRequest["document"] = { + id: JSON.stringify(_.pick(entity, "id", "company_id")), + provider: gr.platformServices.storage.getConnectorType(), + + path: getFilePath(entity), + encryption_algo: this.algorithm, + encryption_key: entity.encryption_key, + chunks: entity.upload_data.chunks, + + filename: entity.metadata.name, + mime: entity.metadata.mime, + }; + const output = { + provider: gr.platformServices.storage.getConnectorType(), + path: `${getFilePath(entity)}/thumbnails/`, + encryption_algo: this.algorithm, + encryption_key: entity.encryption_key, + pages: 10, + }; + return { document, output }; + } + get(id: string, context: CompanyExecutionContext): Promise { if (!id || !context.company.id) { return null; @@ -460,6 +473,10 @@ export class FileServiceImpl { return { success: false }; } } + + getEncryptionAlgorithm(): string { + return this.algorithm; + } } export const getFilePath = (entity: File): string => { return `${gr.platformServices.storage.getHomeDir()}/files/${entity.company_id}/${ diff --git a/tdrive/backend/node/src/services/global-resolver.ts b/tdrive/backend/node/src/services/global-resolver.ts index 31034cac2..f3897b49e 100644 --- a/tdrive/backend/node/src/services/global-resolver.ts +++ b/tdrive/backend/node/src/services/global-resolver.ts @@ -30,9 +30,11 @@ import { CompanyServiceImpl } from "./user/services/companies"; import { UserExternalLinksServiceImpl } from "./user/services/external_links"; import { UserServiceImpl } from "./user/services/users/service"; import { WorkspaceServiceImpl } from "./workspaces/services/workspace"; +import { AVServiceImpl } from "./av/service"; import { PreviewEngine } from "./previews/services/files/engine"; import { I18nService } from "./i18n"; +import { getConfigOrDefault } from "../utils/get-config"; type PlatformServices = { auth: AuthServiceAPI; @@ -67,6 +69,7 @@ type TdriveServices = { documents: DocumentsService; engine: DocumentsEngine; }; + av?: AVServiceImpl; tags: TagsService; i18n: I18nService; }; @@ -132,6 +135,10 @@ class GlobalResolver { i18n: await new I18nService().init(), }; + // AV service is optional + if (getConfigOrDefault("drive.featureAntivirus", false)) + this.services.av = await new AVServiceImpl().init(); + Object.keys(this.services).forEach((key: keyof TdriveServices) => { assert(this.services[key], `Service ${key} was not initialized`); if (this.services[key].constructor.name == "Object") { diff --git a/tdrive/backend/node/src/services/user/utils.ts b/tdrive/backend/node/src/services/user/utils.ts index 92605cc2a..3f93bfbb4 100644 --- a/tdrive/backend/node/src/services/user/utils.ts +++ b/tdrive/backend/node/src/services/user/utils.ts @@ -67,6 +67,9 @@ export function formatCompany( [CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: JSON.parse( config.get("drive.featureManageAccess") || "true", ), + [CompanyFeaturesEnum.COMPANY_AV_ENABLED]: JSON.parse( + config.get("drive.featureAntivirus") || "false", + ), }, { ...(res.plan?.features || {}), diff --git a/tdrive/backend/node/src/services/user/web/schemas.ts b/tdrive/backend/node/src/services/user/web/schemas.ts index 9ee0fe4fc..94b9d3f9d 100644 --- a/tdrive/backend/node/src/services/user/web/schemas.ts +++ b/tdrive/backend/node/src/services/user/web/schemas.ts @@ -95,6 +95,7 @@ export const companyObjectSchema = { [CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]: { type: "boolean" }, [CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" }, [CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" }, + [CompanyFeaturesEnum.COMPANY_AV_ENABLED]: { type: "boolean" }, guests: { type: "number" }, // to rename or delete members: { type: "number" }, // to rename or delete storage: { type: "number" }, // to rename or delete diff --git a/tdrive/backend/node/src/services/user/web/types.ts b/tdrive/backend/node/src/services/user/web/types.ts index 3254dce6d..a7186cc11 100644 --- a/tdrive/backend/node/src/services/user/web/types.ts +++ b/tdrive/backend/node/src/services/user/web/types.ts @@ -87,6 +87,7 @@ export enum CompanyFeaturesEnum { COMPANY_DISPLAY_EMAIL = "company:display_email", COMPANY_USER_QUOTA = "company:user_quota", COMPANY_MANAGE_ACCESS = "company:managed_access", + COMPANY_AV_ENABLED = "company:av_enabled", } export type CompanyFeaturesObject = { @@ -100,6 +101,7 @@ export type CompanyFeaturesObject = { [CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]?: boolean; [CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean; [CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean; + [CompanyFeaturesEnum.COMPANY_AV_ENABLED]?: boolean; }; export type CompanyLimitsObject = { diff --git a/tdrive/backend/node/src/utils/get-config.ts b/tdrive/backend/node/src/utils/get-config.ts new file mode 100644 index 000000000..24d71beb1 --- /dev/null +++ b/tdrive/backend/node/src/utils/get-config.ts @@ -0,0 +1,5 @@ +import config from "config"; + +export const getConfigOrDefault = (key: string, defaultValue: any) => { + return config.has(key) ? config.get(key) : defaultValue; +}; diff --git a/tdrive/backend/node/test/e2e/av/av.spec.ts b/tdrive/backend/node/test/e2e/av/av.spec.ts new file mode 100644 index 000000000..86cdd0260 --- /dev/null +++ b/tdrive/backend/node/test/e2e/av/av.spec.ts @@ -0,0 +1,131 @@ +import "./load_test_config"; +import "reflect-metadata"; +import { afterAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { init, TestPlatform } from "../setup"; +import { deserialize } from "class-transformer"; +import UserApi from "../common/user-api"; +import { DriveItemDetailsMockClass } from "../common/entities/mock_entities"; +import { DocumentsEngine } from "../../../src/services/documents/services/engine"; +import { e2e_createDocumentFile, e2e_createVersion } from "../documents/utils"; +import { ResourceUpdateResponse } from "../../../src/utils/types"; +import { File } from "../../../src/services/files/entities/file"; +import { FileVersion } from "../../../src/services/documents/entities/file-version"; + +describe("The documents antivirus", () => { + let platform: TestPlatform; + const notifyDocumentAVScanAlert = jest.spyOn( + DocumentsEngine.prototype, + "notifyDocumentAVScanAlert", + ); + + beforeEach(async () => { + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "files", + "auth", + "statistics", + "platform-services", + "documents", + ], + }); + }); + + afterAll(async () => { + await platform?.tearDown(); + // @ts-ignore + platform = null; + }); + + describe("On document create", () => { + it("Should scan the document and detect it as safe", async () => { + // Create an admin user + const oneUser = await UserApi.getInstance(platform, true, { companyRole: "admin" }); + const document = await oneUser.uploadFileAndCreateDocument("../../common/assets/sample.doc"); + + expect(document).toBeDefined(); + expect(document.av_status).toBe("scanning"); + await new Promise(resolve => setTimeout(resolve, 5000)); + + const documentResponse = await oneUser.getDocument(document.id); + const deserializedDocument = deserialize( + DriveItemDetailsMockClass, + documentResponse.body, + ); + expect(deserializedDocument).toBeDefined(); + expect(deserializedDocument.item.av_status).toBe("safe"); + }); + + it.skip("Should scan the document and detect it as malicious", async () => { + // Create an admin user + const oneUser = await UserApi.getInstance(platform, true, { companyRole: "admin" }); + const document = await oneUser.uploadTestMalAndCreateDocument("test-malware.txt"); + + expect(document).toBeDefined(); + expect(document.av_status).toBe("scanning"); + }); + + it("Should skip the scan if the document is too large", async () => { + // Create an admin user + const oneUser = await UserApi.getInstance(platform, true, { companyRole: "admin" }); + + // 2.8 MB file > 1 MB limit + const document = await oneUser.uploadFileAndCreateDocument("../../common/assets/sample.mp4"); + + expect(document).toBeDefined(); + expect(document.av_status).toBe("skipped"); + expect(notifyDocumentAVScanAlert).toHaveBeenCalled(); + }); + }); + + describe("On version creation", () => { + it("Should scan the document and detect it as safe.", async () => { + // Create an admin user + const oneUser = await UserApi.getInstance(platform, true, { companyRole: "admin" }); + + // Create a default document for the user + const document = await oneUser.uploadFileAndCreateDocument("../../common/assets/sample.doc"); + + // Upload a file and deserialize the response + const fileUploadResponse = await e2e_createDocumentFile(platform); + const fileUploadResult = deserialize>( + ResourceUpdateResponse, + fileUploadResponse.body, + ); + console.log("🚀🚀 document:: ", document); + console.log("🚀🚀 fileUploadResponseS:: ", fileUploadResponse.body); + + // Prepare metadata with the uploaded file's ID + const fileMetadata = { external_id: fileUploadResult.resource.id }; + + // Create a new version of the document with the uploaded file metadata + const versionResponse = await e2e_createVersion( + platform, + document.id, + { filename: "file2", file_metadata: fileMetadata }, + oneUser.jwt, + ); + const versionResult = deserialize(FileVersion, versionResponse.body); + expect(versionResult).toBeDefined(); + console.log("🚀🚀 VERSION RESULT IS:: ", versionResponse.body); + + // Retrieve the document and verify the antivirus status + const documentResponse = await oneUser.getDocument(versionResult.drive_item_id); + const deserializedDocument = deserialize( + DriveItemDetailsMockClass, + documentResponse.body, + ); + + console.log("🚀🚀 RESP IS:: ", documentResponse.body); + + // Ensure the document has been scanned and is no longer marked as "uploaded" + expect(deserializedDocument.item.av_status).not.toBe("uploaded"); + }); + }); +}); diff --git a/tdrive/backend/node/test/e2e/av/config/runtime.json b/tdrive/backend/node/test/e2e/av/config/runtime.json new file mode 100644 index 000000000..1cb115f64 --- /dev/null +++ b/tdrive/backend/node/test/e2e/av/config/runtime.json @@ -0,0 +1,12 @@ +{ + "drive": { + "featureAntivirus": true + }, + "av": { + "host": "av", + "port": 3310, + "debugMode": false, + "timeout": 2000, + "maxFileSize": 1048576 + } +} diff --git a/tdrive/backend/node/test/e2e/av/load_test_config.ts b/tdrive/backend/node/test/e2e/av/load_test_config.ts new file mode 100644 index 000000000..a2b4026b0 --- /dev/null +++ b/tdrive/backend/node/test/e2e/av/load_test_config.ts @@ -0,0 +1,9 @@ +// @ts-ignore +import path from "path"; +// @ts-ignore +import config from "config"; + +// @ts-ignore +const ourConfigDir = path.join(__dirname, 'config'); +let configs = config.util.loadFileConfigs(ourConfigDir); +config.util.extendDeep(config, configs); \ No newline at end of file diff --git a/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts b/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts index 9bcf5365e..bd212b0d9 100644 --- a/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts +++ b/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts @@ -32,6 +32,7 @@ export class DriveFileMockClass { creator: string; is_directory: boolean; scope: "personal" | "shared"; + av_status: string; created_by: Record; shared_by: Record; } diff --git a/tdrive/backend/node/test/e2e/common/user-api.ts b/tdrive/backend/node/test/e2e/common/user-api.ts index 0ce15cde9..605ee6e64 100644 --- a/tdrive/backend/node/test/e2e/common/user-api.ts +++ b/tdrive/backend/node/test/e2e/common/user-api.ts @@ -29,7 +29,6 @@ import { Response } from "light-my-request"; * in the application. */ export default class UserApi { - private static readonly DOC_URL = "/internal/services/documents/v1"; static readonly ALL_FILES = [ @@ -38,7 +37,7 @@ export default class UserApi { "sample.pdf", "sample.doc", "sample.zip", - "sample.mp4" + "sample.mp4", ]; platform: TestPlatform; @@ -51,9 +50,7 @@ export default class UserApi { api: Api; session: string; - private constructor( - platform: TestPlatform - ) { + private constructor(platform: TestPlatform) { this.platform = platform; } @@ -66,12 +63,14 @@ export default class UserApi { company_id: this.workspace.company_id, }; this.user = await this.dbService.createUser([workspacePK], options, uuidv1()); - this.anonymous = await this.dbService.createUser([workspacePK], + this.anonymous = await this.dbService.createUser( + [workspacePK], { ...options, identity_provider: "anonymous", }, - uuidv1()); + uuidv1(), + ); } else { this.user = this.platform.currentUser; } @@ -128,7 +127,7 @@ export default class UserApi { events: { "http://schemas.openid.net/event/backchannel-logout": {}, }, - } + }, }; const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyLogoutToken"); verifierMock.mockImplementation(() => { @@ -140,28 +139,34 @@ export default class UserApi { }); } - - public static async getInstance(platform: TestPlatform, newUser = false, options?: {}): Promise { + public static async getInstance( + platform: TestPlatform, + newUser = false, + options?: {}, + ): Promise { const helpers = new UserApi(platform); await helpers.init(newUser, options); return helpers; } async uploadRandomFile() { - return await this.uploadFile(UserApi.ALL_FILES[Math.floor((Math.random() * UserApi.ALL_FILES.length))]); + return await this.uploadFile( + UserApi.ALL_FILES[Math.floor(Math.random() * UserApi.ALL_FILES.length)], + ); } - private async injectUploadRequest(readable: Readable | string) { - if (typeof readable === "string") - readable = Readable.from(readable); + private async injectUploadRequest(readable: Readable | string, filename?: string) { + if (typeof readable === "string") readable = Readable.from(readable); const url = "/internal/services/files/v1"; const form = formAutoContent({ file: readable }); form.headers["authorization"] = `Bearer ${this.jwt}`; return await this.platform.app.inject({ method: "POST", - url: `${url}/companies/${this.platform.workspace.company_id}/files?thumbnail_sync=0`, - ...form + url: `${url}/companies/${this.platform.workspace.company_id}/files?thumbnail_sync=0${ + filename ? `&filename=${filename}` : "" + }`, + ...form, }); } @@ -173,38 +178,64 @@ export default class UserApi { if (filesUploadRaw.statusCode == 200) { const filesUpload: ResourceUpdateResponse = deserialize>( ResourceUpdateResponse, - filesUploadRaw.body + filesUploadRaw.body, ); return filesUpload.resource; } else this.throwServerError(filesUploadRaw.statusCode); } private throwServerError(code: number) { - throw new Error("Error code: " + code) + throw new Error("Error code: " + code); } public getJWTTokenForUser(userId: string): string { const payload = { sub: userId, - role: "" + role: "", }; return this.platform.authService.sign(payload); } - async uploadFileAndCreateDocument( - filename: string, - parent_id = "root" - ) { + async uploadEicarTestFile(filename: string) { + // EICAR test file content + const eicarContent = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + // Create a readable stream from the EICAR content + const eicarStream = new Readable(); + eicarStream.push(eicarContent); + eicarStream.push(null); // End of the stream + + // Upload using the stream + const filesUploadRaw = await this.injectUploadRequest(eicarStream, filename); + + if (filesUploadRaw.statusCode === 200) { + const filesUpload = deserialize>( + ResourceUpdateResponse, + filesUploadRaw.body, + ); + console.log("UPLOADED FILE IS: ", filesUpload.resource); + return filesUpload.resource; + } else { + this.throwServerError(filesUploadRaw.statusCode); + } + } + + async uploadFileAndCreateDocument(filename: string, parent_id = "root") { return this.uploadFile(filename).then(f => this.createDocumentFromFile(f, parent_id)); - }; + } + + async uploadTestMalAndCreateDocument(filename: string, parent_id = "root") { + return this.uploadEicarTestFile(filename).then(f => this.createDocumentFromFile(f, parent_id)); + } async uploadRandomFileAndCreateDocument(parent_id = "root") { return this.uploadRandomFile().then(f => this.createDocumentFromFile(f, parent_id)); - }; + } async uploadAllFilesAndCreateDocuments(parent_id = "root") { - return await Promise.all(UserApi.ALL_FILES.map(f => this.uploadFileAndCreateDocument(f, parent_id))); - }; + return await Promise.all( + UserApi.ALL_FILES.map(f => this.uploadFileAndCreateDocument(f, parent_id)), + ); + } async uploadAllFilesOneByOne(parent_id = "root") { const files: Array = []; @@ -214,16 +245,19 @@ export default class UserApi { files.push(doc); } return files; - }; + } async createDirectory(parent = "root", overrides?: Partial) { - const directory = await this.createDocument({ - company_id: this.platform.workspace.company_id, - name: "Test Folder Name", - parent_id: parent, - is_directory: true, - ...overrides - }, {}); + const directory = await this.createDocument( + { + company_id: this.platform.workspace.company_id, + name: "Test Folder Name", + parent_id: parent, + is_directory: true, + ...overrides, + }, + {}, + ); expect(directory).toBeDefined(); expect(directory).not.toBeNull(); expect(directory.id).toBeDefined(); @@ -245,25 +279,28 @@ export default class UserApi { } /** Gets the public link access token then `impersonateWithJWT` as an anonymous user with that link */ - async impersonatePublicLinkAccessOf(item: Partial & { id: string }, cb: () => Promise): Promise { + async impersonatePublicLinkAccessOf( + item: Partial & { id: string }, + cb: () => Promise, + ): Promise { const publicToken = await this.getPublicLinkAccessToken(item); expect(publicToken?.value?.length ?? "").toBeGreaterThan(0); return this.impersonateWithJWT(publicToken?.value, cb); } - async createDocument( - item: Partial, - version: Partial - ) { - const response = await this.api.post(`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item`, + async createDocument(item: Partial, version: Partial) { + const response = await this.api.post( + `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item`, { item, - version - }, { - authorization: `Bearer ${this.jwt}` - }); + version, + }, + { + authorization: `Bearer ${this.jwt}`, + }, + ); return deserialize(DriveFile, response.body); - }; + } async createDefaultDocument(overrides?: Partial): Promise { const scope: "personal" | "shared" = "shared"; @@ -276,28 +313,38 @@ export default class UserApi { }; return await this.createDocument(item, {}); - }; + } - async shareWithPublicLink(doc: Partial & { id: string }, accessLevel: publicAccessLevel) { + async shareWithPublicLink( + doc: Partial & { id: string }, + accessLevel: publicAccessLevel, + ) { return await this.updateDocument(doc.id, { ...doc, access_info: { ...doc.access_info!, public: { ...doc.access_info?.public!, - level: accessLevel - } - } + level: accessLevel, + }, + }, }); } - async shareWithPublicLinkWithOkCheck(doc: Partial & { id: string }, accessLevel: publicAccessLevel) { - const shareResponse = await this.shareWithPublicLink(doc, accessLevel); + async shareWithPublicLinkWithOkCheck( + doc: Partial & { id: string }, + accessLevel: publicAccessLevel, + ) { + const shareResponse = await this.shareWithPublicLink(doc, accessLevel); expect(shareResponse.statusCode).toBe(200); return deserialize(DriveFile, shareResponse.body); } - async shareWithPermissions(doc: Partial & { id: string }, toUserId: string, permissions: DriveFileAccessLevel) { + async shareWithPermissions( + doc: Partial & { id: string }, + toUserId: string, + permissions: DriveFileAccessLevel, + ) { doc.access_info.entities.push({ type: "user", id: toUserId, @@ -316,21 +363,19 @@ export default class UserApi { payload: { company_id: doc.company_id, document_id: doc.id, - token: doc.access_info.public?.token - } + token: doc.access_info.public?.token, + }, }); const { access_token } = deserialize( AccessTokenMockClass, - accessRes.body + accessRes.body, ); expect(access_token).toBeDefined(); return access_token; } - async createRandomDocument( - parent_id = "root" - ) { + async createRandomDocument(parent_id = "root") { const file = await this.uploadRandomFile(); const doc = await this.createDocumentFromFile(file, parent_id); @@ -340,7 +385,7 @@ export default class UserApi { expect(doc.parent_id).toEqual(parent_id); return doc; - }; + } async createDocumentFromFilename( file_name: @@ -361,16 +406,13 @@ export default class UserApi { expect(doc.parent_id).toEqual(parent_id); return doc; - }; + } - async createDocumentFromFile( - file: File, - parent_id = "root" - ) { + async createDocumentFromFile(file: File, parent_id = "root") { const item = { name: file.metadata.name, parent_id: parent_id, - company_id: file.company_id + company_id: file.company_id, }; const version = { @@ -378,35 +420,31 @@ export default class UserApi { name: file.metadata.name, size: file.upload_data?.size, thumbnails: [], - external_id: file.id - } + external_id: file.id, + }, }; return await this.createDocument(item, version); - }; + } - async updateDocument( - id: string | "root" | "trash" | "shared_with_me", - item: Partial - ) { + async updateDocument(id: string | "root" | "trash" | "shared_with_me", item: Partial) { return await this.api.post( `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`, item, { - authorization: `Bearer ${this.jwt}` - }); - }; + authorization: `Bearer ${this.jwt}`, + }, + ); + } - async beginEditingDocument( - driveFileId: string, - editorApplicationId: string, - ): Promise { + async beginEditingDocument(driveFileId: string, editorApplicationId: string): Promise { return await this.api.post( `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${driveFileId}/editing_session`, { editorApplicationId }, { - authorization: `Bearer ${this.jwt}` - }); + authorization: `Bearer ${this.jwt}`, + }, + ); } async updateEditingDocument( @@ -415,7 +453,7 @@ export default class UserApi { userId: string | null = null, ): Promise { const fullPath = `${__dirname}/assets/${UserApi.ALL_FILES[0]}`; - const readable= Readable.from(fs.createReadStream(fullPath)); + const readable = Readable.from(fs.createReadStream(fullPath)); const form = formAutoContent({ file: readable }); form.headers["authorization"] = `Bearer ${this.jwt}`; let queryString = keepEditing ? "keepEditing=true" : ""; @@ -423,23 +461,23 @@ export default class UserApi { queryString += `${queryString.length ? "&" : ""}userId=${encodeURIComponent(userId)}`; return await this.platform.app.inject({ method: "POST", - url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editingSessionKey)}${queryString ? "?" : ""}${queryString}`, + url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editingSessionKey)}${ + queryString ? "?" : "" + }${queryString}`, headers: { - authorization: `Bearer ${this.jwt}` + authorization: `Bearer ${this.jwt}`, }, ...form, }); } - async cancelEditingDocument( - editingSessionKey: string, - ): Promise { + async cancelEditingDocument(editingSessionKey: string): Promise { return await this.platform.app.inject({ method: "DELETE", url: `${UserApi.DOC_URL}/editing_session/${editingSessionKey}`, headers: { - authorization: `Bearer ${this.jwt}` - } + authorization: `Bearer ${this.jwt}`, + }, }); } @@ -449,65 +487,56 @@ export default class UserApi { ): Promise { const result = await this.beginEditingDocument(driveFileId, editorApplicationId); expect(result.statusCode).toBe(200); - const {editingSessionKey} = result.json(); + const { editingSessionKey } = result.json(); expect(editingSessionKey).toBeTruthy(); return editingSessionKey; } - async searchDocument( - payload: Record - ) { + async searchDocument(payload: Record) { const response = await this.platform.app.inject({ method: "POST", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/search`, headers: { - authorization: `Bearer ${this.jwt}` + authorization: `Bearer ${this.jwt}`, }, - payload + payload, }); - return deserialize( - SearchResultMockClass, - response.body); - }; + return deserialize(SearchResultMockClass, response.body); + } - async browseDocuments( - id: string, - payload: Record = {} - ) { + async browseDocuments(id: string, payload: Record = {}) { const response = await this.platform.app.inject({ method: "POST", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/${id}`, headers: { - authorization: `Bearer ${this.jwt}` + authorization: `Bearer ${this.jwt}`, }, - payload + payload, }); - return deserialize( - DriveItemDetailsMockClass, - response.body); - }; + return deserialize(DriveItemDetailsMockClass, response.body); + } async getDocument(id: string | "root" | "trash" | "shared_with_me") { return await this.platform.app.inject({ method: "GET", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`, headers: { - authorization: `Bearer ${this.jwt}` - } + authorization: `Bearer ${this.jwt}`, + }, }); - }; + } async zipDocument(id: string | "root" | "trash" | "shared_with_me") { return await this.platform.app.inject({ method: "GET", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/download/zip?items=${id}`, headers: { - authorization: `Bearer ${this.jwt}` - } + authorization: `Bearer ${this.jwt}`, + }, }); - }; + } async getDocumentOKCheck(id: string | "root" | "trash" | "shared_with_me") { const response = await this.getDocument(id); @@ -515,42 +544,38 @@ export default class UserApi { const doc = deserialize(DriveItemDetailsMockClass, response.body); expect(doc.item?.id).toBe(id); return doc; - }; + } async getDocumentByEditingKey(editing_session_key: string) { return await this.platform.app.inject({ method: "GET", url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editing_session_key)}`, headers: { - authorization: `Bearer ${this.jwt}` - } + authorization: `Bearer ${this.jwt}`, + }, }); - }; + } - async sharedWithMeDocuments( - payload: Record - ) { + async sharedWithMeDocuments(payload: Record) { const response = await this.platform.app.inject({ method: "POST", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/shared_with_me`, headers: { - authorization: `Bearer ${this.jwt}` + authorization: `Bearer ${this.jwt}`, }, - payload + payload, }); - return deserialize( - DriveItemDetailsMockClass, - response.body); - }; + return deserialize(DriveItemDetailsMockClass, response.body); + } async quota() { const url = "/internal/services/users/v1/users"; const response = await this.platform.app.inject({ method: "GET", - headers: { "authorization": `Bearer ${this.jwt}` }, - url: `${url}/${this.user.id}/quota?companyId=${this.platform.workspace.company_id}` + headers: { authorization: `Bearer ${this.jwt}` }, + url: `${url}/${this.user.id}/quota?companyId=${this.platform.workspace.company_id}`, }); return deserialize(UserQuotaMockClass, response.body); @@ -560,7 +585,7 @@ export default class UserApi { return await this.platform.app.inject({ method: "DELETE", url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`, - headers: { "authorization": `Bearer ${this.jwt}` }, + headers: { authorization: `Bearer ${this.jwt}` }, }); } } diff --git a/tdrive/backend/node/test/e2e/documents/documents-pagination-sorting.spec.ts b/tdrive/backend/node/test/e2e/documents/documents-pagination-sorting.spec.ts index abcd3712e..842fc601d 100644 --- a/tdrive/backend/node/test/e2e/documents/documents-pagination-sorting.spec.ts +++ b/tdrive/backend/node/test/e2e/documents/documents-pagination-sorting.spec.ts @@ -35,6 +35,8 @@ describe("The Documents Browser Window and API", () => { for (const file of files) { await currentUser.shareWithPermissions(file, anotherUser.user.id, "read"); } + // for opensearch to index the files + await new Promise(resolve => setTimeout(resolve, 3000)); }); afterAll(async () => { @@ -147,6 +149,7 @@ describe("The Documents Browser Window and API", () => { it("Should paginate shared with me ", async () => { let page_token: any = "1"; const limitStr = "2"; + let docs = await anotherUser.browseDocuments(sharedWIthMeFolder, { paginate: { page_token, limitStr }, }); diff --git a/tdrive/backend/node/yarn.lock b/tdrive/backend/node/yarn.lock index e8edb3ad6..877f208b7 100644 --- a/tdrive/backend/node/yarn.lock +++ b/tdrive/backend/node/yarn.lock @@ -2372,6 +2372,11 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz" integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q== +clamscan@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz" + integrity sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw== + class-transformer@^0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz" diff --git a/tdrive/docker-compose.dev.mongo.yml b/tdrive/docker-compose.dev.mongo.yml index 6bf92ae3b..991de77be 100644 --- a/tdrive/docker-compose.dev.mongo.yml +++ b/tdrive/docker-compose.dev.mongo.yml @@ -10,6 +10,14 @@ services: - 27017:27017 networks: - tdrive_network + + av: + image: clamav/clamav:latest + container_name: av + ports: + - 3310:3310 + networks: + - tdrive_network node: build: diff --git a/tdrive/docker-compose.dev.tests.opensearch.yml b/tdrive/docker-compose.dev.tests.opensearch.yml index 3cac70f1d..7c025bd0d 100644 --- a/tdrive/docker-compose.dev.tests.opensearch.yml +++ b/tdrive/docker-compose.dev.tests.opensearch.yml @@ -3,7 +3,6 @@ version: "3.4" # docker-compose -f docker-compose.dev.tests.mongo.yml stop mongo; rm -R docker-data/mongo/; docker-compose -f docker-compose.dev.tests.mongo.yml run -e SEARCH_DRIVER=mongodb -e DB_DRIVER=mongodb node npm run test:e2e services: - opensearch-node1: image: opensearchproject/opensearch:2.11.0 # Specifying the latest available image - modify if you want a specific version container_name: opensearch-node1 @@ -13,7 +12,7 @@ services: - discovery.seed_hosts=opensearch-node1 # Nodes to look for when discovering the cluster - cluster.initial_cluster_manager_nodes=opensearch-node1 # Nodes eligible to serve as cluster manager - bootstrap.memory_lock=true # Disable JVM heap memory swapping -# - OPENSEARCH_INITIAL_ADMIN_PASSWORD=admin + # - OPENSEARCH_INITIAL_ADMIN_PASSWORD=admin - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # Set min and max JVM heap sizes to at least 50% of system RAM ulimits: memlock: @@ -22,13 +21,13 @@ services: nofile: soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536 hard: 65536 -# volumes: -# - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container + # volumes: + # - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container ports: - 9200:9200 # REST API -# - 9600:9600 # Performance Analyzer -# networks: - # - opensearch-net # All of the containers will join the same Docker bridge network + # - 9600:9600 # Performance Analyzer + # networks: + # - opensearch-net # All of the containers will join the same Docker bridge network postgres: image: postgres @@ -40,6 +39,12 @@ services: ports: - "5432:5432" + av: + image: clamav/clamav:latest + container_name: av + ports: + - 3310:3310 + node: # Use the build context in the current directory build: @@ -66,6 +71,8 @@ services: depends_on: - postgres - opensearch-node1 + - av links: - postgres - opensearch-node1 + - av diff --git a/tdrive/docker-compose.tests.postgresql.yml b/tdrive/docker-compose.tests.postgresql.yml index 5b53f32c8..9855191e1 100644 --- a/tdrive/docker-compose.tests.postgresql.yml +++ b/tdrive/docker-compose.tests.postgresql.yml @@ -1,7 +1,6 @@ version: "3.4" services: - postgres: image: postgres restart: always diff --git a/tdrive/docker-compose.tests.yml b/tdrive/docker-compose.tests.yml index a78e9dcc5..515b6cc4e 100644 --- a/tdrive/docker-compose.tests.yml +++ b/tdrive/docker-compose.tests.yml @@ -14,6 +14,16 @@ services: - 27017:27017 healthcheck: test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + av: + image: clamav/clamav:latest + container_name: av + ports: + - 3310:3310 + healthcheck: + test: ["CMD-SHELL", "echo 'PING' | nc -w 5 localhost 3310"] + interval: 30s + timeout: 10s + retries: 5 redis: image: "redis:alpine" @@ -79,6 +89,8 @@ services: condition: service_healthy mongo: condition: service_healthy + av: + condition: service_healthy # rabbitmq: # condition: service_started elasticsearch: @@ -87,6 +99,7 @@ services: condition: service_completed_successfully links: - mongo + - av # - rabbitmq elasticsearch: diff --git a/tdrive/frontend/public/locales/en.json b/tdrive/frontend/public/locales/en.json index dd7b17d51..6b24cb83e 100644 --- a/tdrive/frontend/public/locales/en.json +++ b/tdrive/frontend/public/locales/en.json @@ -80,6 +80,7 @@ "components.item_context_menu.move.modal_header": "Move", "components.item_context_menu.move_multiple": "Move selected items", "components.item_context_menu.move_multiple.modal_header": "Move selected items", + "components.item_context_menu.rescan_document": "Re-scan for viruses", "components.item_context_menu.move_to_trash": "Delete", "components.item_context_menu.open_new_window": "Open in new window", "components.item_context_menu.preview": "Preview", @@ -198,6 +199,8 @@ "hooks.use-drive-actions.unable_restore_file": "Unable to restore this item.", "hooks.use-drive-actions.unable_update_file": "Unable to update this file.", "hooks.use-drive-actions.update_caused_a_rename": "Item was renamed to '{{$2}}'.", + "hooks.use-drive-actions.av_confirm_file_download": "This file may not be safe. Are you sure you want to proceed with the download?", + "hooks.use-drive-actions.av_confirm_folder_download": "This folder contains potentially malicious files. Are you sure you want to proceed with the download?", "login.create_account": "Create account", "login.login_error": "Error during login", "molecules.download_banner.download_button": "Download desktop app", @@ -211,6 +214,10 @@ "scenes.app.drive.folders": "Folders", "scenes.app.drive.nothing": "Nothing here.", "scenes.app.drive.used": "used in this folder", + "scenes.app.drive.document_row.av_scanning": "Scanning", + "scenes.app.drive.document_row.av_malicious": "Malicious", + "scenes.app.drive.document_row.av_skipped": "Skipped", + "scenes.app.drive.document_row.av_scan_failed": "Scan failed", "scenes.app.header.disconnected": "You are disconnected", "scenes.app.header.disconnected.reload": "Reload", "scenes.app.mainview.create_account": "Create your workspace for free on ", diff --git a/tdrive/frontend/public/locales/fr.json b/tdrive/frontend/public/locales/fr.json index 82bda2487..a10a2ecc1 100644 --- a/tdrive/frontend/public/locales/fr.json +++ b/tdrive/frontend/public/locales/fr.json @@ -80,6 +80,7 @@ "components.item_context_menu.move.modal_header": "Déplacer", "components.item_context_menu.move_multiple": "Déplacer", "components.item_context_menu.move_multiple.modal_header": "Déplacer les éléments sélectionnés", + "components.item_context_menu.rescan_document": "Re-scanner pour les virus", "components.item_context_menu.move_to_trash": "Supprimer", "components.item_context_menu.open_new_window": "Ouvrir dans une nouvelle fenêtre", "components.item_context_menu.preview": "Aperçu", @@ -191,6 +192,8 @@ "hooks.use-drive-actions.unable_restore_file": "Impossible de restaurer cet élément.", "hooks.use-drive-actions.unable_update_file": "Impossible de mettre à jour ce fichier", "hooks.use-drive-actions.update_caused_a_rename": "Renommé en '{{$2}}'.", + "hooks.use-drive-actions.av_confirm_file_download": "Ce fichier peut ne pas être sûr. Êtes-vous sûr de vouloir continuer le téléchargement ?", + "hooks.use-drive-actions.av_confirm_folder_download": "Ce dossier contient des fichiers potentiellement malveillants. Êtes-vous sûr de vouloir continuer le téléchargement ?", "login.create_account": "Créer un compte", "login.login_error": "Erreur lors de la connexion", "molecules.download_banner.download_button": "Télécharger l'application de bureau", @@ -204,6 +207,10 @@ "scenes.app.drive.folders": "Dossiers", "scenes.app.drive.nothing": "Il n'y a rien ici.", "scenes.app.drive.used": "utilisés dans ce dossier", + "scenes.app.drive.document_row.av_scanning": "Analyse en cours", + "scenes.app.drive.document_row.av_malicious": "Malveillant", + "scenes.app.drive.document_row.av_skipped": "Ignoré", + "scenes.app.drive.document_row.av_scan_failed": "Analyse échouée", "scenes.app.header.disconnected": "Vous êtes déconnecté", "scenes.app.header.disconnected.reload": "Recharger", "scenes.app.mainview.create_account": "Créez votre espace de travail gratuitement sur ", diff --git a/tdrive/frontend/public/locales/ru.json b/tdrive/frontend/public/locales/ru.json index faa07d8e4..fd84e29bd 100644 --- a/tdrive/frontend/public/locales/ru.json +++ b/tdrive/frontend/public/locales/ru.json @@ -80,6 +80,7 @@ "components.item_context_menu.move.modal_header": "Переместить", "components.item_context_menu.move_multiple": "Переместить все", "components.item_context_menu.move_multiple.modal_header": "Переместить выбранные елементы", + "components.item_context_menu.rescan_document": "Повторное сканирование на вирусы", "components.item_context_menu.move_to_trash": "Удалить", "components.item_context_menu.open_new_window": "Открыть в новом окне", "components.item_context_menu.preview": "Просмотр", @@ -198,6 +199,8 @@ "hooks.use-drive-actions.unable_restore_file": "Невозможно восстановить эти файлы.", "hooks.use-drive-actions.unable_update_file": "Невозможно обновить эти файлы.", "hooks.use-drive-actions.update_caused_a_rename": "Элемент был переименован в «{{$2}}».", + "hooks.use-drive-actions.av_confirm_file_download": "Этот файл может быть небезопасным. Вы уверены, что хотите продолжить загрузку?", + "hooks.use-drive-actions.av_confirm_folder_download": "Эта папка может содержать вредоносные файлы. Вы уверены, что хотите продолжить загрузку?", "login.create_account": "Создать учетную запись", "login.login_error": "Ошибка при входе в систему", "molecules.download_banner.download_button": "Скачать настольное приложение", @@ -211,6 +214,10 @@ "scenes.app.drive.folders": "Папки", "scenes.app.drive.nothing": "Здесь ничего нет.", "scenes.app.drive.used": "использовано в этой папке", + "scenes.app.drive.document_row.av_scanning": "Сканирование", + "scenes.app.drive.document_row.av_malicious": "Вредоносное", + "scenes.app.drive.document_row.av_skipped": "Пропущено", + "scenes.app.drive.document_row.av_scan_failed": "Сканирование не удалось", "scenes.app.header.disconnected": "Соединение отсутствует", "scenes.app.header.disconnected.reload": "Перезагрузить", "scenes.app.mainview.create_account": "Создайте свою рабочую среду бесплатно в ", diff --git a/tdrive/frontend/public/locales/vi.json b/tdrive/frontend/public/locales/vi.json index 8e4813abe..4cbf011a5 100644 --- a/tdrive/frontend/public/locales/vi.json +++ b/tdrive/frontend/public/locales/vi.json @@ -77,6 +77,7 @@ "components.item_context_menu.move.modal_header": "Di chuyển", "components.item_context_menu.move_multiple": "Di chuyển các mục đã chọn", "components.item_context_menu.move_multiple.modal_header": "Di chuyển các mục đã chọn", + "components.item_context_menu.rescan_document": "Quét lại để tìm virus", "components.item_context_menu.move_to_trash": "Xóa", "components.item_context_menu.open_new_window": "Mở trong cửa sổ mới", "components.item_context_menu.preview": "Xem trước", @@ -180,6 +181,8 @@ "hooks.use-drive-actions.unable_restore_file": "Không thể khôi phục mục này.", "hooks.use-drive-actions.unable_update_file": "Không thể cập nhật tệp này.", "hooks.use-drive-actions.update_caused_a_rename": "Mục đã được đổi tên thành '{{$2}}'.", + "hooks.use-drive-actions.av_confirm_file_download": "Tệp này có thể không an toàn. Bạn có chắc chắn muốn tiếp tục tải xuống không?", + "hooks.use-drive-actions.av_confirm_folder_download": "Thư mục này có thể chứa tệp độc hại. Bạn có chắc chắn muốn tiếp tục tải xuống không?", "login.create_account": "Tạo tài khoản", "login.login_error": "Lỗi trong khi đăng nhập", "molecules.download_banner.download_button": "Tải xuống ứng dụng dành cho máy tính", @@ -193,6 +196,10 @@ "scenes.app.drive.folders": "Thư mục", "scenes.app.drive.nothing": "Không có gì ở đây.", "scenes.app.drive.used": "được sử dụng trong thư mục này", + "scenes.app.drive.document_row.av_scanning": "Đang quét", + "scenes.app.drive.document_row.av_malicious": "Độc hại", + "scenes.app.drive.document_row.av_skipped": "Bỏ qua", + "scenes.app.drive.document_row.av_scan_failed": "Quét không thành công", "scenes.app.header.disconnected": "Bạn đang ngoại tuyến", "scenes.app.header.disconnected.reload": "Tải lại", "scenes.app.mainview.create_account": "Tạo không gian làm việc của bạn miễn phí trên ", diff --git a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts index b82244c26..e0f897573 100644 --- a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts +++ b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts @@ -135,6 +135,20 @@ export class DriveApiClient { ); } + static async checkMalware(companyId: string, id: string) { + return await Api.post( + `/internal/services/documents/v1/companies/${companyId}/item/${id}/check_malware${appendTdriveToken()}`, + {}, + ); + } + + static async reScan(companyId: string, id: string) { + return await Api.post( + `/internal/services/documents/v1/companies/${companyId}/item/${id}/rescan${appendTdriveToken()}`, + {}, + ); + } + static getDownloadUrl(companyId: string, id: string, versionId?: string) { if (versionId) return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}`); diff --git a/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx b/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx index 7fb472445..8a035c5fe 100644 --- a/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx +++ b/tdrive/frontend/src/app/features/drive/hooks/use-drive-actions.tsx @@ -3,12 +3,20 @@ import useRouterCompany from '@features/router/hooks/use-router-company'; import { useCallback } from 'react'; import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil'; import { DriveApiClient } from '../api-client/api-client'; -import { DriveItemAtom, DriveItemChildrenAtom, DriveItemPagination, DriveItemSort } from '../state/store'; +import { + DriveItemAtom, + DriveItemChildrenAtom, + DriveItemPagination, + DriveItemSort, +} from '../state/store'; import { BrowseFilter, DriveItem, DriveItemVersion } from '../types'; import { SharedWithMeFilterState } from '../state/shared-with-me-filter'; import Languages from 'features/global/services/languages-service'; import { useUserQuota } from 'features/users/hooks/use-user-quota'; - +import AlertManager from 'app/features/global/services/alert-manager-service'; +import FeatureTogglesService, { + FeatureNames, +} from '@features/global/services/feature-toggles-service'; /** * Returns the children of a drive item * @returns @@ -19,6 +27,7 @@ export const useDriveActions = (inPublicSharing?: boolean) => { const sortItem = useRecoilValue(DriveItemSort); const [ paginateItem ] = useRecoilState(DriveItemPagination); const { getQuota } = useUserQuota(); + const AVEnabled = FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED); const refresh = useRecoilCallback( ({ set, snapshot }) => @@ -35,7 +44,13 @@ export const useDriveActions = (inPublicSharing?: boolean) => { set(DriveItemPagination, pagination); } try { - const details = await DriveApiClient.browse(companyId, parentId, filter, sortItem, pagination); + const details = await DriveApiClient.browse( + companyId, + parentId, + filter, + sortItem, + pagination, + ); set(DriveItemChildrenAtom(parentId), details.children); set(DriveItemAtom(parentId), details); for (const child of details.children) { @@ -87,10 +102,31 @@ export const useDriveActions = (inPublicSharing?: boolean) => { ); const download = useCallback( - async (id: string, versionId?: string) => { + async (id: string, isMalicious = false, versionId?: string) => { try { const url = DriveApiClient.getDownloadUrl(companyId, id, versionId); - (window as any).open(url, '_blank').focus(); + // if AV is enabled + if (AVEnabled) { + // if the file is malicious + if (isMalicious) { + // toggle confirm for user + AlertManager.confirm( + () => { + (window as any).open(url, '_blank').focus(); + }, + () => { + return; + }, + { + text: Languages.t('hooks.use-drive-actions.av_confirm_file_download'), + }, + ); + } else { + (window as any).open(url, '_blank').focus(); + } + } else { + (window as any).open(url, '_blank').focus(); + } } catch (e) { ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file')); } @@ -99,10 +135,34 @@ export const useDriveActions = (inPublicSharing?: boolean) => { ); const downloadZip = useCallback( - async (ids: string[], isDirectory = false) => { + async (ids: string[], isDirectory = false, containsMalicious = false) => { try { - const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory); - (window as any).open(url, '_blank').focus(); + const triggerDownload = async () => { + const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory); + (window as any).open(url, '_blank').focus(); + }; + if (AVEnabled) { + const containsMaliciousFiles = + containsMalicious || + (ids.length === 1 && (await DriveApiClient.checkMalware(companyId, ids[0]))); + if (containsMaliciousFiles) { + AlertManager.confirm( + async () => { + await triggerDownload(); + }, + () => { + return; + }, + { + text: Languages.t('hooks.use-drive-actions.av_confirm_folder_download'), + }, + ); + } else { + await triggerDownload(); + } + } else { + await triggerDownload(); + } } catch (e) { ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file')); } @@ -142,7 +202,12 @@ export const useDriveActions = (inPublicSharing?: boolean) => { try { const newItem = await DriveApiClient.update(companyId, id, update); if (previousName && previousName !== newItem.name && !update.name) - ToasterService.warn(Languages.t('hooks.use-drive-actions.update_caused_a_rename', [previousName, newItem.name])); + ToasterService.warn( + Languages.t('hooks.use-drive-actions.update_caused_a_rename', [ + previousName, + newItem.name, + ]), + ); await refresh(id || '', true); if (!inPublicSharing) await refresh(parentId || '', true); if (update?.parent_id !== parentId) await refresh(update?.parent_id || '', true); @@ -183,12 +248,47 @@ export const useDriveActions = (inPublicSharing?: boolean) => { parentId, filter, sortItem, - pagination + pagination, ); return details; }, [paginateItem, refresh], ); + + const checkMalware = useCallback( + async (item: Partial) => { + try { + await DriveApiClient.checkMalware(companyId, item.id || ''); + } catch (e) { + ToasterService.error(Languages.t('hooks.use-drive-actions.unable_rescan_file')); + } + }, + [refresh], + ); + + const reScan = useCallback( + async (item: Partial) => { + try { + await DriveApiClient.reScan(companyId, item.id || ''); + await refresh(item.parent_id || '', true); + } catch (e) { + ToasterService.error(Languages.t('hooks.use-drive-actions.unable_rescan_file')); + } + }, + [refresh], + ); - return { create, refresh, download, downloadZip, remove, restore, update, updateLevel, nextPage }; + return { + create, + refresh, + download, + downloadZip, + remove, + restore, + update, + updateLevel, + reScan, + checkMalware, + nextPage, + }; }; diff --git a/tdrive/frontend/src/app/features/drive/types.ts b/tdrive/frontend/src/app/features/drive/types.ts index fd605799f..5e12d6235 100644 --- a/tdrive/frontend/src/app/features/drive/types.ts +++ b/tdrive/frontend/src/app/features/drive/types.ts @@ -56,6 +56,7 @@ export type DriveItem = { size: number; scope: string; + av_status: string; }; export type DriveFileAccessLevelForInherited = 'none' | 'manage'; diff --git a/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts b/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts index d61f8865f..8e792a227 100644 --- a/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts +++ b/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts @@ -12,6 +12,7 @@ export enum FeatureNames { COMPANY_DISPLAY_EMAIL = 'company:display_email', COMPANY_USER_QUOTA = 'company:user_quota', COMPANY_MANAGE_ACCESS = 'company:managed_access', + COMPANY_AV_ENABLED = 'company:av_enabled', } export type FeatureValueType = boolean | number; @@ -31,6 +32,7 @@ availableFeaturesWithDefaults.set(FeatureNames.COMPANY_SHARED_DRIVE, true); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_DISPLAY_EMAIL, true); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_USER_QUOTA, false); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_MANAGE_ACCESS, true); +availableFeaturesWithDefaults.set(FeatureNames.COMPANY_AV_ENABLED, false); /** * ChannelServiceImpl that allow you to manage feature flipping in Tdrive using react feature toggles diff --git a/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx b/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx index 0ffa72c1d..df258cc5f 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx @@ -44,7 +44,7 @@ export const useOnBuildContextMenu = ( DriveCurrentFolderAtom({ initialFolderId: initialParentId || 'root' }), ); - const { download, downloadZip, update, restore } = useDriveActions(); + const { download, downloadZip, update, restore, reScan } = useDriveActions(); const setCreationModalState = useSetRecoilState(CreateModalAtom); const setUploadModalState = useSetRecoilState(UploadModelAtom); const setSelectorModalState = useSetRecoilState(SelectorModalAtom); @@ -66,6 +66,8 @@ export const useOnBuildContextMenu = ( const inTrash = parent.path?.[0]?.id.includes('trash') || viewId?.includes('trash'); const isPersonal = item?.scope === 'personal'; const selectedCount = checked.length; + const notSafe = + !item?.is_directory && !['uploaded', 'safe'].includes(item?.av_status || ''); let menu: any[] = []; @@ -75,26 +77,41 @@ export const useOnBuildContextMenu = ( const access = upToDateItem.access || 'none'; const hideShareItem = access === 'read' || getPublicLinkToken() || inTrash; const hideManageAccessItem = - access === 'read' - || getPublicLinkToken() - || inTrash - || !FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS); + access === 'read' || + getPublicLinkToken() || + inTrash || + !FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS); const newMenuActions = [ { type: 'menu', icon: 'share-alt', text: Languages.t('components.item_context_menu.share'), - hide: hideShareItem, + hide: hideShareItem || notSafe, onClick: () => setPublicLinkModalState({ open: true, id: item.id }), }, { type: 'menu', icon: 'users-alt', text: Languages.t('components.item_context_menu.manage_access'), - hide: hideManageAccessItem, + hide: hideManageAccessItem || notSafe, onClick: () => setAccessModalState({ open: true, id: item.id }), }, - { type: 'separator', hide: inTrash || (hideShareItem && hideManageAccessItem) }, + { + type: 'menu', + icon: 'shield-check', + text: Languages.t('components.item_context_menu.rescan_document'), + hide: !(item.av_status === 'scan_failed'), + onClick: () => { + reScan(item); + }, + }, + { + type: 'separator', + hide: + inTrash || + (hideShareItem && hideManageAccessItem) || + (notSafe && !(item.av_status === 'scan_failed')), + }, { type: 'menu', icon: 'download-alt', @@ -104,7 +121,7 @@ export const useOnBuildContextMenu = ( downloadZip([item!.id]); console.log(item!.id); } else { - download(item.id); + download(item.id, notSafe); } }, }, @@ -120,12 +137,12 @@ export const useOnBuildContextMenu = ( window.open(route, '_blank'); } }, // */ - { type: 'separator' }, + { type: 'separator', hide: notSafe }, { type: 'menu', icon: 'folder-question', text: Languages.t('components.item_context_menu.move'), - hide: access === 'read' || inTrash || inPublicSharing, + hide: access === 'read' || inTrash || inPublicSharing || notSafe, onClick: () => setSelectorModalState({ open: true, @@ -150,7 +167,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'file-edit-alt', text: Languages.t('components.item_context_menu.rename'), - hide: access === 'read' || inTrash, + hide: access === 'read' || inTrash || notSafe, onClick: () => setPropertiesModalState({ open: true, id: item.id, inPublicSharing }), }, { @@ -160,7 +177,8 @@ export const useOnBuildContextMenu = ( hide: !item.access_info.public?.level || item.access_info.public?.level === 'none' || - inTrash, + inTrash || + notSafe, onClick: () => { copyToClipboard(getPublicLink(item || parent?.item)); ToasterService.success( @@ -172,10 +190,10 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'history', text: Languages.t('components.item_context_menu.versions'), - hide: item.is_directory || inTrash, + hide: item.is_directory || inTrash || notSafe, onClick: () => setVersionModal({ open: true, id: item.id }), }, - { type: 'separator', hide: access !== 'manage' || inTrash }, + { type: 'separator', hide: access !== 'manage' || inTrash || notSafe }, { type: 'menu', icon: 'trash', @@ -238,15 +256,25 @@ export const useOnBuildContextMenu = ( type: 'menu', text: Languages.t('components.item_context_menu.download_multiple'), hide: inTrash, - onClick: () => - selectedCount === 1 ? download(checked[0].id) : downloadZip(checked.map(c => c.id)), + onClick: () => { + const containsMalicious = checked.some(c => c.av_status === 'malicious'); + if (selectedCount === 1) { + download(checked[0].id); + } else { + downloadZip( + checked.map(c => c.id), + false, + containsMalicious, + ); + } + }, }, { type: 'menu', text: Languages.t('components.item_context_menu.clear_selection'), onClick: () => setChecked({}), }, - { type: 'separator', hide: parent.access === 'read' }, + { type: 'separator', hide: parent.access === 'read' || notSafe }, { type: 'menu', text: Languages.t('components.item_context_menu.delete_multiple'), @@ -517,7 +545,9 @@ export const useOnBuildFileContextMenu = () => { { type: 'menu', text: Languages.t('components.item_context_menu.download'), - onClick: () => download(item.id), + onClick: () => { + download(item.id); + }, }, ]; return menuItems; diff --git a/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx b/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx index 68b68e11b..20586b21d 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx @@ -1,19 +1,29 @@ -import { DotsHorizontalIcon } from '@heroicons/react/outline'; +import { + DotsHorizontalIcon, + ShieldCheckIcon, + ShieldExclamationIcon, + BanIcon, +} from '@heroicons/react/outline'; import { Button } from '@atoms/button/button'; import { Base, BaseSmall } from '@atoms/text'; import Menu from '@components/menus/menu'; import useRouterCompany from '@features/router/hooks/use-router-company'; import { useDrivePreview } from '@features/drive/hooks/use-drive-preview'; import { formatBytes } from '@features/drive/utils'; +import Languages from '@features/global/services/languages-service'; import { useState } from 'react'; import { PublicIcon } from '../components/public-icon'; import { CheckableIcon, DriveItemOverlayProps, DriveItemProps } from './common'; + import './style.scss'; import { useHistory } from 'react-router-dom'; import RouterServices from '@features/router/services/router-service'; import { DocumentIcon } from './document-icon'; import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers'; import { formatDateShort } from 'app/features/global/utils/Numbers'; +import FeatureTogglesService, { + FeatureNames, +} from '@features/global/services/feature-toggles-service'; export const DocumentRow = ({ item, @@ -27,6 +37,7 @@ export const DocumentRow = ({ const [hover, setHover] = useState(false); const {open} = useDrivePreview(); const company = useRouterCompany(); + const notSafe = ['malicious', 'skipped', 'scan_failed'].includes(item.av_status); const preview = () => { open(item); @@ -39,7 +50,7 @@ export const DocumentRow = ({ className={ 'flex flex-row items-center border border-zinc-200 dark:border-zinc-800 px-4 py-3 cursor-pointer ' + (checked - ? 'bg-blue-500 bg-opacity-10 hover:bg-opacity-25 ' + ? (notSafe ? 'bg-rose-500' : 'bg-blue-500') + ' bg-opacity-10 hover:bg-opacity-25' : 'hover:bg-zinc-500 hover:bg-opacity-10 ') + (className || '') } @@ -48,7 +59,9 @@ export const DocumentRow = ({ onClick={e => { if (e.shiftKey || e.ctrlKey) onCheck(!checked); else if (onClick) onClick(); - else preview(); + else { + if (!notSafe) preview(); + } }} >
{formatBytes(item.size)}
+ {FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED) && ( +
+ + {item?.av_status === 'scanning' && ( + + )} + {item?.av_status === 'malicious' && ( + + )} + {item?.av_status === 'skipped' && } + {item?.av_status === 'scan_failed' && } + +
+ )}
-
diff --git a/tdrive/frontend/src/app/views/error/error-boundary.tsx b/tdrive/frontend/src/app/views/error/error-boundary.tsx index 74f2d8f9b..f3510a792 100755 --- a/tdrive/frontend/src/app/views/error/error-boundary.tsx +++ b/tdrive/frontend/src/app/views/error/error-boundary.tsx @@ -26,7 +26,7 @@ export default class ErrorBoundary extends React.Component