diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f1036eb3..7b42a6a2 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 766fdabf..3e337b86 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 2d72b797..30da3a6f 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 9b3db99d..02f6ad54 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 a241213b..6bc3eb2b 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 00000000..0338a612
--- /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 🚫" }
+
+
+ |
+
+
+
+
+ `
+ ]
+}) %>
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 00000000..9549bc30
--- /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 00000000..6b4e6fd4
--- /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é 🚫" }
+
+
+ |
+
+
+
+
+ `
+ ]
+}) %>
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 00000000..9ff7e6e0
--- /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 00000000..7642a724
--- /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 00000000..614971b8
--- /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 00000000..8774d891
--- /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 5a793abc..2676681b 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 930643a1..a0b17933 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 8f3b840d..221a3233 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 fc38bbfa..74084356 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 a503e9ef..8e5963f6 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 348971fa..145706aa 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 0b58856f..ad08e8fe 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 65384de7..3703ea45 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 4ade5c6f..233d1dd8 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 31034cac..f3897b49 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 92605cc2..3f93bfbb 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 9ee0fe4f..94b9d3f9 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 3254dce6..a7186cc1 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 00000000..24d71beb
--- /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 00000000..86cdd026
--- /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 00000000..1cb115f6
--- /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 00000000..a2b4026b
--- /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 9bcf5365..bd212b0d 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 0ce15cde..605ee6e6 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 abcd3712..842fc601 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 e8edb3ad..877f208b 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 6bf92ae3..991de77b 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 3cac70f1..7c025bd0 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 5b53f32c..9855191e 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 a78e9dcc..515b6cc4 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 dd7b17d5..6b24cb83 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 82bda248..a10a2ecc 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 faa07d8e..fd84e29b 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 8e4813ab..4cbf011a 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 b82244c2..e0f89757 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 7fb47244..8a035c5f 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],
+ );
- return { create, refresh, download, downloadZip, remove, restore, update, updateLevel, nextPage };
+ 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,
+ reScan,
+ checkMalware,
+ nextPage,
+ };
};
diff --git a/tdrive/frontend/src/app/features/drive/types.ts b/tdrive/frontend/src/app/features/drive/types.ts
index fd605799..5e12d623 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 d61f8865..8e792a22 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 0ffa72c1..df258cc5 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 68b68e11..20586b21 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' && }
+
+
+ )}