🐞 Add antivirus inside Twake Drive (#725)
🐞 Add antivirus inside Twake Drive (#725)
This commit is contained in:
@@ -67,7 +67,7 @@ jobs:
|
|||||||
- name: e2e-opensearch-test
|
- name: e2e-opensearch-test
|
||||||
run: |
|
run: |
|
||||||
cd tdrive
|
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
|
sleep 60
|
||||||
docker compose -f docker-compose.dev.tests.opensearch.yml logs
|
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
|
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
|
||||||
|
|||||||
@@ -135,6 +135,14 @@
|
|||||||
"defaultUserQuota": "DRIVE_DEFAULT_USER_QUOTA",
|
"defaultUserQuota": "DRIVE_DEFAULT_USER_QUOTA",
|
||||||
"featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL",
|
"featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL",
|
||||||
"featureUserQuota": "ENABLE_FEATURE_USER_QUOTA",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,15 @@
|
|||||||
"featureUserQuota": false,
|
"featureUserQuota": false,
|
||||||
"featureManageAccess": true,
|
"featureManageAccess": true,
|
||||||
"defaultCompany": "00000000-0000-4000-0000-000000000000",
|
"defaultCompany": "00000000-0000-4000-0000-000000000000",
|
||||||
"defaultUserQuota": 200000000
|
"defaultUserQuota": 200000000,
|
||||||
|
"featureAntivirus": false
|
||||||
|
},
|
||||||
|
"av": {
|
||||||
|
"host": "av",
|
||||||
|
"port": 3310,
|
||||||
|
"debugMode": false,
|
||||||
|
"timeout": 2000,
|
||||||
|
"maxFileSize": 26214400
|
||||||
},
|
},
|
||||||
"applications": {
|
"applications": {
|
||||||
"grid": [
|
"grid": [
|
||||||
|
|||||||
Generated
+9
@@ -34,6 +34,7 @@
|
|||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
|
"clamscan": "^2.4.0",
|
||||||
"class-transformer": "^0.3.1",
|
"class-transformer": "^0.3.1",
|
||||||
"cli-table": "^0.3.6",
|
"cli-table": "^0.3.6",
|
||||||
"config": "^3.3.2",
|
"config": "^3.3.2",
|
||||||
@@ -4473,6 +4474,14 @@
|
|||||||
"integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==",
|
"integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/class-transformer": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz",
|
||||||
|
|||||||
@@ -131,6 +131,7 @@
|
|||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
|
"clamscan": "^2.4.0",
|
||||||
"class-transformer": "^0.3.1",
|
"class-transformer": "^0.3.1",
|
||||||
"cli-table": "^0.3.6",
|
"cli-table": "^0.3.6",
|
||||||
"config": "^3.3.2",
|
"config": "^3.3.2",
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<% layout('./_structure') %>
|
||||||
|
<% it.title = 'Twake Drive Antivirus Alert' %>
|
||||||
|
|
||||||
|
<%~ includeFile("../common/_body.eta", {
|
||||||
|
paragraphs: [
|
||||||
|
`
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#FF0000;">
|
||||||
|
Important: Antivirus Alert on Your Twake Drive
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="font-weight: 500">Antivirus scan for a file on your Twake Drive has flagged an issue.</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="font-weight: 500">
|
||||||
|
File: ${it.notifications[0].item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="color: #FF0000; font-weight: 600;">
|
||||||
|
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 🚫" }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<a class="main-button" href="${it.encodedUrl}">
|
||||||
|
View File Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
]
|
||||||
|
}) %>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Twake Drive Antivirus Alert
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<% layout('./_structure') %>
|
||||||
|
<% it.title = 'Alerte Antivirus dans votre Twake Drive' %>
|
||||||
|
|
||||||
|
<%~ includeFile("../common/_body.eta", {
|
||||||
|
paragraphs: [
|
||||||
|
`
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#FF0000;">
|
||||||
|
Important: Alerte Antivirus dans votre Twake Drive
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="font-weight: 500">L'analyse antivirus d'un fichier dans votre Twake Drive a signalé un problème.</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="font-weight: 500">
|
||||||
|
Fichier: ${it.notifications[0].item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
|
||||||
|
<span style="color: #FF0000; font-weight: 600;">
|
||||||
|
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é 🚫" }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
|
||||||
|
<a class="main-button" href="${it.encodedUrl}">
|
||||||
|
Voir sur Twake Drive
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
]
|
||||||
|
}) %>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Alerte Antivirus dans votre Twake Drive
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { TdriveService } from "../../core/platform/framework";
|
||||||
|
|
||||||
|
export default class AVService extends TdriveService<undefined> {
|
||||||
|
version = "1";
|
||||||
|
name = "antivirus";
|
||||||
|
|
||||||
|
public async doInit(): Promise<this> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove
|
||||||
|
api(): undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<this> {
|
||||||
|
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<DriveFile>,
|
||||||
|
version: Partial<FileVersion>,
|
||||||
|
onScanComplete: (status: AVStatus) => Promise<void>,
|
||||||
|
context: DriveExecutionContext,
|
||||||
|
): Promise<AVStatus> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import * as UUIDTools from "../../../utils/uuid";
|
|||||||
|
|
||||||
export const TYPE = "drive_files";
|
export const TYPE = "drive_files";
|
||||||
export type DriveScope = "personal" | "shared";
|
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.
|
* 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
|
* if `scope == "personal"`, otherwise the trash of the shared drive
|
||||||
* - `"trash_$userid"`: Trash folder for a given user (same note as `"user_$userid"`)
|
* - `"trash_$userid"`: Trash folder for a given user (same note as `"user_$userid"`)
|
||||||
* - `"shared_with_me"`: for the feature of the same name
|
* - `"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, {
|
@Entity(TYPE, {
|
||||||
globalIndexes: [
|
globalIndexes: [
|
||||||
@@ -122,6 +135,10 @@ export class DriveFile {
|
|||||||
@Type(() => String)
|
@Type(() => String)
|
||||||
@Column("scope", "string")
|
@Column("scope", "string")
|
||||||
scope: DriveScope;
|
scope: DriveScope;
|
||||||
|
|
||||||
|
@Type(() => String)
|
||||||
|
@Column("av_status", "string")
|
||||||
|
av_status: AVStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnlyOfficeSafeDocKeyBase64 = {
|
const OnlyOfficeSafeDocKeyBase64 = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import globalResolver from "../../../global-resolver";
|
|||||||
import { logger } from "../../../../core/platform/framework";
|
import { logger } from "../../../../core/platform/framework";
|
||||||
import { localEventBus } from "../../../../core/platform/framework/event-bus";
|
import { localEventBus } from "../../../../core/platform/framework/event-bus";
|
||||||
import { Initializable } from "../../../../core/platform/framework";
|
import { Initializable } from "../../../../core/platform/framework";
|
||||||
import { DocumentEvents, NotificationPayloadType } from "../../types";
|
import { DocumentEvents, NotificationPayloadType, eventToTemplateMap } from "../../types";
|
||||||
import { DocumentsProcessor } from "./extract-keywords";
|
import { DocumentsProcessor } from "./extract-keywords";
|
||||||
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
|
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
|
||||||
import { DriveFile, TYPE } from "../../entities/drive-file";
|
import { DriveFile, TYPE } from "../../entities/drive-file";
|
||||||
@@ -17,10 +17,14 @@ export class DocumentsEngine implements Initializable {
|
|||||||
id: e.context.company.id,
|
id: e.context.company.id,
|
||||||
});
|
});
|
||||||
const language = receiver.preferences?.language || "en";
|
const language = receiver.preferences?.language || "en";
|
||||||
const emailTemplate =
|
|
||||||
event === DocumentEvents.DOCUMENT_SAHRED
|
const emailTemplate = eventToTemplateMap[event];
|
||||||
? "notification-document-shared"
|
|
||||||
: "notification-document-version-updated";
|
if (!emailTemplate) {
|
||||||
|
logger.error(`Error dispatching document event. Unknown event type: ${event}`);
|
||||||
|
return; // Early return on unknown event type
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { html, text, subject } = await globalResolver.platformServices.emailPusher.build(
|
const { html, text, subject } = await globalResolver.platformServices.emailPusher.build(
|
||||||
emailTemplate,
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,4 +88,8 @@ export class DocumentsEngine implements Initializable {
|
|||||||
notifyDocumentVersionUpdated(notificationPayload: NotificationPayloadType) {
|
notifyDocumentVersionUpdated(notificationPayload: NotificationPayloadType) {
|
||||||
localEventBus.publish(DocumentEvents.DOCUMENT_VERSION_UPDATED, notificationPayload);
|
localEventBus.publish(DocumentEvents.DOCUMENT_VERSION_UPDATED, notificationPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyDocumentAVScanAlert(notificationPayload: NotificationPayloadType) {
|
||||||
|
localEventBus.publish(DocumentEvents.DOCUMENT_AV_SCAN_ALERT, notificationPayload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { PublicFile } from "../../../services/files/entities/file";
|
|||||||
import globalResolver from "../../../services/global-resolver";
|
import globalResolver from "../../../services/global-resolver";
|
||||||
import { hasCompanyAdminLevel } from "../../../utils/company";
|
import { hasCompanyAdminLevel } from "../../../utils/company";
|
||||||
import gr from "../../global-resolver";
|
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 { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
|
||||||
import User, { TYPE as UserType } from "../../user/entities/user";
|
import User, { TYPE as UserType } from "../../user/entities/user";
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
updateItemSize,
|
updateItemSize,
|
||||||
isInTrash,
|
isInTrash,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
import { getConfigOrDefault } from "../../../utils/get-config";
|
||||||
import {
|
import {
|
||||||
checkAccess,
|
checkAccess,
|
||||||
getAccessLevel,
|
getAccessLevel,
|
||||||
@@ -58,7 +59,6 @@ import {
|
|||||||
} from "./access-check";
|
} from "./access-check";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
import internal from "stream";
|
import internal from "stream";
|
||||||
import config from "config";
|
|
||||||
import { MultipartFile } from "@fastify/multipart";
|
import { MultipartFile } from "@fastify/multipart";
|
||||||
import { UploadOptions } from "src/services/files/types";
|
import { UploadOptions } from "src/services/files/types";
|
||||||
import { SortType } from "src/core/platform/services/search/api";
|
import { SortType } from "src/core/platform/services/search/api";
|
||||||
@@ -73,15 +73,9 @@ export class DocumentsService {
|
|||||||
userRepository: Repository<User>;
|
userRepository: Repository<User>;
|
||||||
ROOT: RootType = "root";
|
ROOT: RootType = "root";
|
||||||
TRASH: TrashType = "trash";
|
TRASH: TrashType = "trash";
|
||||||
quotaEnabled: boolean = config.has("drive.featureUserQuota")
|
quotaEnabled: boolean = getConfigOrDefault("drive.featureUserQuota", false);
|
||||||
? config.get("drive.featureUserQuota")
|
defaultQuota: number = getConfigOrDefault("drive.defaultUserQuota", 0);
|
||||||
: false;
|
manageAccessEnabled: boolean = getConfigOrDefault("drive.featureManageAccess", false);
|
||||||
defaultQuota: number = config.has("drive.defaultUserQuota")
|
|
||||||
? config.get("drive.defaultUserQuota")
|
|
||||||
: 0;
|
|
||||||
manageAccessEnabled: boolean = config.has("drive.featureManageAccess")
|
|
||||||
? config.get("drive.featureManageAccess")
|
|
||||||
: false;
|
|
||||||
logger: TdriveLogger = getLogger("Documents Service");
|
logger: TdriveLogger = getLogger("Documents Service");
|
||||||
|
|
||||||
async init(): Promise<this> {
|
async init(): Promise<this> {
|
||||||
@@ -391,9 +385,8 @@ export class DocumentsService {
|
|||||||
throw Error("User does not have access to this item parent");
|
throw Error("User does not have access to this item parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fileToProcess;
|
||||||
if (file || driveItem.is_directory === false) {
|
if (file || driveItem.is_directory === false) {
|
||||||
let fileToProcess;
|
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
fileToProcess = file;
|
fileToProcess = file;
|
||||||
} else if (driveItemVersion.file_metadata.external_id) {
|
} 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
|
//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);
|
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<DocumentsMessageQueueRequest>(
|
await globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
|
||||||
"services:documents:process",
|
"services:documents:process",
|
||||||
{
|
{
|
||||||
@@ -545,7 +577,13 @@ export class DocumentsService {
|
|||||||
throw Error("content mismatch");
|
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;
|
let renamedTo: string | undefined;
|
||||||
for (const key of updatable) {
|
for (const key of updatable) {
|
||||||
if ((content as any)[key]) {
|
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);
|
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 (await isInTrash(item, this.repository, context)) {
|
||||||
if (item.is_in_trash != true) {
|
if (item.is_in_trash != true) {
|
||||||
if (item.scope === "personal") {
|
if (item.scope === "personal") {
|
||||||
@@ -921,6 +965,13 @@ export class DocumentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const driveItemVersion = getDefaultDriveItemVersion(version, context);
|
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);
|
const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context);
|
||||||
|
|
||||||
// if quota is enabled, check if the user has enough space
|
// 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);
|
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<DocumentsMessageQueueRequest>(
|
await globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
|
||||||
"services:documents:process",
|
"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<boolean>} - the check result
|
||||||
|
*/
|
||||||
|
containsMaliciousFiles = async (id: string, context: DriveExecutionContext): Promise<boolean> => {
|
||||||
|
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<DriveFile>} - the DriveFile after the rescan has been triggered
|
||||||
|
*/
|
||||||
|
rescan = async (id: string, context: DriveExecutionContext): Promise<DriveFile> => {
|
||||||
|
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
|
* 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
|
* `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";
|
sortField[sortFieldMapping[sort?.by] || "last_modified"] = sort?.order || "desc";
|
||||||
return sortField;
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,15 @@ export type DriveTdriveTab = {
|
|||||||
export enum DocumentEvents {
|
export enum DocumentEvents {
|
||||||
DOCUMENT_SAHRED = "document_shared",
|
DOCUMENT_SAHRED = "document_shared",
|
||||||
DOCUMENT_VERSION_UPDATED = "document_version_updated",
|
DOCUMENT_VERSION_UPDATED = "document_version_updated",
|
||||||
|
DOCUMENT_AV_SCAN_ALERT = "document_av_scan_alert",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const eventToTemplateMap: Record<string, any> = {
|
||||||
|
[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 = {
|
export type NotificationPayloadType = {
|
||||||
context: CompanyExecutionContext;
|
context: CompanyExecutionContext;
|
||||||
item: DriveFile;
|
item: DriveFile;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const getDefaultDriveItem = (
|
|||||||
parent_id: item.parent_id || "root",
|
parent_id: item.parent_id || "root",
|
||||||
content_keywords: item.content_keywords || "",
|
content_keywords: item.content_keywords || "",
|
||||||
scope: "personal",
|
scope: "personal",
|
||||||
|
av_status: "uploaded",
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
access_info: item.access_info || {
|
access_info: item.access_info || {
|
||||||
entities: [
|
entities: [
|
||||||
|
|||||||
@@ -335,6 +335,63 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if directory contains malicious files
|
||||||
|
*
|
||||||
|
* @param {FastifyRequest} request
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
containsMaliciousFiles = async (
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Params: ItemRequestParams;
|
||||||
|
Querystring: { public_token?: string };
|
||||||
|
}>,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
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<DriveFile>}
|
||||||
|
*/
|
||||||
|
rescan = async (
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Params: ItemRequestParams;
|
||||||
|
Body: Partial<any>;
|
||||||
|
Querystring: { public_token?: string };
|
||||||
|
}>,
|
||||||
|
): Promise<DriveFile | any> => {
|
||||||
|
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
|
* 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
|
* @returns The `editing_session_key` that was either set or already was there
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next)
|
|||||||
handler: documentsController.beginEditing.bind(documentsController),
|
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({
|
fastify.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: editingSessionBase, //TODO NONONO check authenticate*Optional*
|
url: editingSessionBase, //TODO NONONO check authenticate*Optional*
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const documentSchema = {
|
|||||||
attachements: { type: "array" },
|
attachements: { type: "array" },
|
||||||
last_version_cache: fileVersionSchema,
|
last_version_cache: fileVersionSchema,
|
||||||
scope: { type: "string" },
|
scope: { type: "string" },
|
||||||
|
av_status: { type: "string" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -149,59 +149,8 @@ export class FileServiceImpl {
|
|||||||
entity.upload_data.size = totalUploadedSize;
|
entity.upload_data.size = totalUploadedSize;
|
||||||
await this.repository.save(entity, context);
|
await this.repository.save(entity, context);
|
||||||
|
|
||||||
/** Send preview generation task */
|
/** Send preview generation task if av is not enabled */
|
||||||
if (entity.upload_data.size < this.max_preview_file_size) {
|
if (!gr.services.av?.avEnabled) await this.generatePreview(entity, options, context);
|
||||||
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<PreviewMessageQueueRequest>(
|
|
||||||
"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 */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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<PreviewMessageQueueRequest>(
|
||||||
|
"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<File> {
|
get(id: string, context: CompanyExecutionContext): Promise<File> {
|
||||||
if (!id || !context.company.id) {
|
if (!id || !context.company.id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -460,6 +473,10 @@ export class FileServiceImpl {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEncryptionAlgorithm(): string {
|
||||||
|
return this.algorithm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export const getFilePath = (entity: File): string => {
|
export const getFilePath = (entity: File): string => {
|
||||||
return `${gr.platformServices.storage.getHomeDir()}/files/${entity.company_id}/${
|
return `${gr.platformServices.storage.getHomeDir()}/files/${entity.company_id}/${
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ import { CompanyServiceImpl } from "./user/services/companies";
|
|||||||
import { UserExternalLinksServiceImpl } from "./user/services/external_links";
|
import { UserExternalLinksServiceImpl } from "./user/services/external_links";
|
||||||
import { UserServiceImpl } from "./user/services/users/service";
|
import { UserServiceImpl } from "./user/services/users/service";
|
||||||
import { WorkspaceServiceImpl } from "./workspaces/services/workspace";
|
import { WorkspaceServiceImpl } from "./workspaces/services/workspace";
|
||||||
|
import { AVServiceImpl } from "./av/service";
|
||||||
|
|
||||||
import { PreviewEngine } from "./previews/services/files/engine";
|
import { PreviewEngine } from "./previews/services/files/engine";
|
||||||
import { I18nService } from "./i18n";
|
import { I18nService } from "./i18n";
|
||||||
|
import { getConfigOrDefault } from "../utils/get-config";
|
||||||
|
|
||||||
type PlatformServices = {
|
type PlatformServices = {
|
||||||
auth: AuthServiceAPI;
|
auth: AuthServiceAPI;
|
||||||
@@ -67,6 +69,7 @@ type TdriveServices = {
|
|||||||
documents: DocumentsService;
|
documents: DocumentsService;
|
||||||
engine: DocumentsEngine;
|
engine: DocumentsEngine;
|
||||||
};
|
};
|
||||||
|
av?: AVServiceImpl;
|
||||||
tags: TagsService;
|
tags: TagsService;
|
||||||
i18n: I18nService;
|
i18n: I18nService;
|
||||||
};
|
};
|
||||||
@@ -132,6 +135,10 @@ class GlobalResolver {
|
|||||||
i18n: await new I18nService().init(),
|
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) => {
|
Object.keys(this.services).forEach((key: keyof TdriveServices) => {
|
||||||
assert(this.services[key], `Service ${key} was not initialized`);
|
assert(this.services[key], `Service ${key} was not initialized`);
|
||||||
if (this.services[key].constructor.name == "Object") {
|
if (this.services[key].constructor.name == "Object") {
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export function formatCompany(
|
|||||||
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: JSON.parse(
|
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: JSON.parse(
|
||||||
config.get("drive.featureManageAccess") || "true",
|
config.get("drive.featureManageAccess") || "true",
|
||||||
),
|
),
|
||||||
|
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]: JSON.parse(
|
||||||
|
config.get("drive.featureAntivirus") || "false",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...(res.plan?.features || {}),
|
...(res.plan?.features || {}),
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export const companyObjectSchema = {
|
|||||||
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]: { type: "boolean" },
|
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]: { type: "boolean" },
|
||||||
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" },
|
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" },
|
||||||
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" },
|
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" },
|
||||||
|
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]: { type: "boolean" },
|
||||||
guests: { type: "number" }, // to rename or delete
|
guests: { type: "number" }, // to rename or delete
|
||||||
members: { type: "number" }, // to rename or delete
|
members: { type: "number" }, // to rename or delete
|
||||||
storage: { type: "number" }, // to rename or delete
|
storage: { type: "number" }, // to rename or delete
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export enum CompanyFeaturesEnum {
|
|||||||
COMPANY_DISPLAY_EMAIL = "company:display_email",
|
COMPANY_DISPLAY_EMAIL = "company:display_email",
|
||||||
COMPANY_USER_QUOTA = "company:user_quota",
|
COMPANY_USER_QUOTA = "company:user_quota",
|
||||||
COMPANY_MANAGE_ACCESS = "company:managed_access",
|
COMPANY_MANAGE_ACCESS = "company:managed_access",
|
||||||
|
COMPANY_AV_ENABLED = "company:av_enabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyFeaturesObject = {
|
export type CompanyFeaturesObject = {
|
||||||
@@ -100,6 +101,7 @@ export type CompanyFeaturesObject = {
|
|||||||
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]?: boolean;
|
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]?: boolean;
|
||||||
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean;
|
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean;
|
||||||
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean;
|
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean;
|
||||||
|
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanyLimitsObject = {
|
export type CompanyLimitsObject = {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import config from "config";
|
||||||
|
|
||||||
|
export const getConfigOrDefault = (key: string, defaultValue: any) => {
|
||||||
|
return config.has(key) ? config.get(key) : defaultValue;
|
||||||
|
};
|
||||||
@@ -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>(
|
||||||
|
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<File>>(
|
||||||
|
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>(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>(
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"drive": {
|
||||||
|
"featureAntivirus": true
|
||||||
|
},
|
||||||
|
"av": {
|
||||||
|
"host": "av",
|
||||||
|
"port": 3310,
|
||||||
|
"debugMode": false,
|
||||||
|
"timeout": 2000,
|
||||||
|
"maxFileSize": 1048576
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -32,6 +32,7 @@ export class DriveFileMockClass {
|
|||||||
creator: string;
|
creator: string;
|
||||||
is_directory: boolean;
|
is_directory: boolean;
|
||||||
scope: "personal" | "shared";
|
scope: "personal" | "shared";
|
||||||
|
av_status: string;
|
||||||
created_by: Record<string, any>;
|
created_by: Record<string, any>;
|
||||||
shared_by: Record<string, any>;
|
shared_by: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { Response } from "light-my-request";
|
|||||||
* in the application.
|
* in the application.
|
||||||
*/
|
*/
|
||||||
export default class UserApi {
|
export default class UserApi {
|
||||||
|
|
||||||
private static readonly DOC_URL = "/internal/services/documents/v1";
|
private static readonly DOC_URL = "/internal/services/documents/v1";
|
||||||
|
|
||||||
static readonly ALL_FILES = [
|
static readonly ALL_FILES = [
|
||||||
@@ -38,7 +37,7 @@ export default class UserApi {
|
|||||||
"sample.pdf",
|
"sample.pdf",
|
||||||
"sample.doc",
|
"sample.doc",
|
||||||
"sample.zip",
|
"sample.zip",
|
||||||
"sample.mp4"
|
"sample.mp4",
|
||||||
];
|
];
|
||||||
|
|
||||||
platform: TestPlatform;
|
platform: TestPlatform;
|
||||||
@@ -51,9 +50,7 @@ export default class UserApi {
|
|||||||
api: Api;
|
api: Api;
|
||||||
session: string;
|
session: string;
|
||||||
|
|
||||||
private constructor(
|
private constructor(platform: TestPlatform) {
|
||||||
platform: TestPlatform
|
|
||||||
) {
|
|
||||||
this.platform = platform;
|
this.platform = platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +63,14 @@ export default class UserApi {
|
|||||||
company_id: this.workspace.company_id,
|
company_id: this.workspace.company_id,
|
||||||
};
|
};
|
||||||
this.user = await this.dbService.createUser([workspacePK], options, uuidv1());
|
this.user = await this.dbService.createUser([workspacePK], options, uuidv1());
|
||||||
this.anonymous = await this.dbService.createUser([workspacePK],
|
this.anonymous = await this.dbService.createUser(
|
||||||
|
[workspacePK],
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
identity_provider: "anonymous",
|
identity_provider: "anonymous",
|
||||||
},
|
},
|
||||||
uuidv1());
|
uuidv1(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.user = this.platform.currentUser;
|
this.user = this.platform.currentUser;
|
||||||
}
|
}
|
||||||
@@ -128,7 +127,7 @@ export default class UserApi {
|
|||||||
events: {
|
events: {
|
||||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyLogoutToken");
|
const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyLogoutToken");
|
||||||
verifierMock.mockImplementation(() => {
|
verifierMock.mockImplementation(() => {
|
||||||
@@ -140,28 +139,34 @@ export default class UserApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async getInstance(
|
||||||
public static async getInstance(platform: TestPlatform, newUser = false, options?: {}): Promise<UserApi> {
|
platform: TestPlatform,
|
||||||
|
newUser = false,
|
||||||
|
options?: {},
|
||||||
|
): Promise<UserApi> {
|
||||||
const helpers = new UserApi(platform);
|
const helpers = new UserApi(platform);
|
||||||
await helpers.init(newUser, options);
|
await helpers.init(newUser, options);
|
||||||
return helpers;
|
return helpers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadRandomFile() {
|
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) {
|
private async injectUploadRequest(readable: Readable | string, filename?: string) {
|
||||||
if (typeof readable === "string")
|
if (typeof readable === "string") readable = Readable.from(readable);
|
||||||
readable = Readable.from(readable);
|
|
||||||
const url = "/internal/services/files/v1";
|
const url = "/internal/services/files/v1";
|
||||||
const form = formAutoContent({ file: readable });
|
const form = formAutoContent({ file: readable });
|
||||||
form.headers["authorization"] = `Bearer ${this.jwt}`;
|
form.headers["authorization"] = `Bearer ${this.jwt}`;
|
||||||
|
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${url}/companies/${this.platform.workspace.company_id}/files?thumbnail_sync=0`,
|
url: `${url}/companies/${this.platform.workspace.company_id}/files?thumbnail_sync=0${
|
||||||
...form
|
filename ? `&filename=${filename}` : ""
|
||||||
|
}`,
|
||||||
|
...form,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,38 +178,64 @@ export default class UserApi {
|
|||||||
if (filesUploadRaw.statusCode == 200) {
|
if (filesUploadRaw.statusCode == 200) {
|
||||||
const filesUpload: ResourceUpdateResponse<File> = deserialize<ResourceUpdateResponse<File>>(
|
const filesUpload: ResourceUpdateResponse<File> = deserialize<ResourceUpdateResponse<File>>(
|
||||||
ResourceUpdateResponse,
|
ResourceUpdateResponse,
|
||||||
filesUploadRaw.body
|
filesUploadRaw.body,
|
||||||
);
|
);
|
||||||
return filesUpload.resource;
|
return filesUpload.resource;
|
||||||
} else this.throwServerError(filesUploadRaw.statusCode);
|
} else this.throwServerError(filesUploadRaw.statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private throwServerError(code: number) {
|
private throwServerError(code: number) {
|
||||||
throw new Error("Error code: " + code)
|
throw new Error("Error code: " + code);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getJWTTokenForUser(userId: string): string {
|
public getJWTTokenForUser(userId: string): string {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: userId,
|
sub: userId,
|
||||||
role: ""
|
role: "",
|
||||||
};
|
};
|
||||||
return this.platform.authService.sign(payload);
|
return this.platform.authService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFileAndCreateDocument(
|
async uploadEicarTestFile(filename: string) {
|
||||||
filename: string,
|
// EICAR test file content
|
||||||
parent_id = "root"
|
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<File>>(
|
||||||
|
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));
|
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") {
|
async uploadRandomFileAndCreateDocument(parent_id = "root") {
|
||||||
return this.uploadRandomFile().then(f => this.createDocumentFromFile(f, parent_id));
|
return this.uploadRandomFile().then(f => this.createDocumentFromFile(f, parent_id));
|
||||||
};
|
}
|
||||||
|
|
||||||
async uploadAllFilesAndCreateDocuments(parent_id = "root") {
|
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") {
|
async uploadAllFilesOneByOne(parent_id = "root") {
|
||||||
const files: Array<DriveFile> = [];
|
const files: Array<DriveFile> = [];
|
||||||
@@ -214,16 +245,19 @@ export default class UserApi {
|
|||||||
files.push(doc);
|
files.push(doc);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
};
|
}
|
||||||
|
|
||||||
async createDirectory(parent = "root", overrides?: Partial<DriveFile>) {
|
async createDirectory(parent = "root", overrides?: Partial<DriveFile>) {
|
||||||
const directory = await this.createDocument({
|
const directory = await this.createDocument(
|
||||||
company_id: this.platform.workspace.company_id,
|
{
|
||||||
name: "Test Folder Name",
|
company_id: this.platform.workspace.company_id,
|
||||||
parent_id: parent,
|
name: "Test Folder Name",
|
||||||
is_directory: true,
|
parent_id: parent,
|
||||||
...overrides
|
is_directory: true,
|
||||||
}, {});
|
...overrides,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
expect(directory).toBeDefined();
|
expect(directory).toBeDefined();
|
||||||
expect(directory).not.toBeNull();
|
expect(directory).not.toBeNull();
|
||||||
expect(directory.id).toBeDefined();
|
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 */
|
/** Gets the public link access token then `impersonateWithJWT` as an anonymous user with that link */
|
||||||
async impersonatePublicLinkAccessOf<T>(item: Partial<DriveFile> & { id: string }, cb: () => Promise<T>): Promise<T> {
|
async impersonatePublicLinkAccessOf<T>(
|
||||||
|
item: Partial<DriveFile> & { id: string },
|
||||||
|
cb: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
const publicToken = await this.getPublicLinkAccessToken(item);
|
const publicToken = await this.getPublicLinkAccessToken(item);
|
||||||
expect(publicToken?.value?.length ?? "").toBeGreaterThan(0);
|
expect(publicToken?.value?.length ?? "").toBeGreaterThan(0);
|
||||||
return this.impersonateWithJWT(publicToken?.value, cb);
|
return this.impersonateWithJWT(publicToken?.value, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDocument(
|
async createDocument(item: Partial<DriveFile>, version: Partial<FileVersion>) {
|
||||||
item: Partial<DriveFile>,
|
const response = await this.api.post(
|
||||||
version: Partial<FileVersion>
|
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item`,
|
||||||
) {
|
|
||||||
const response = await this.api.post(`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item`,
|
|
||||||
{
|
{
|
||||||
item,
|
item,
|
||||||
version
|
version,
|
||||||
}, {
|
},
|
||||||
authorization: `Bearer ${this.jwt}`
|
{
|
||||||
});
|
authorization: `Bearer ${this.jwt}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
return deserialize<DriveFile>(DriveFile, response.body);
|
return deserialize<DriveFile>(DriveFile, response.body);
|
||||||
};
|
}
|
||||||
|
|
||||||
async createDefaultDocument(overrides?: Partial<DriveFile>): Promise<DriveFile> {
|
async createDefaultDocument(overrides?: Partial<DriveFile>): Promise<DriveFile> {
|
||||||
const scope: "personal" | "shared" = "shared";
|
const scope: "personal" | "shared" = "shared";
|
||||||
@@ -276,28 +313,38 @@ export default class UserApi {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return await this.createDocument(item, {});
|
return await this.createDocument(item, {});
|
||||||
};
|
}
|
||||||
|
|
||||||
async shareWithPublicLink(doc: Partial<DriveFile> & { id: string }, accessLevel: publicAccessLevel) {
|
async shareWithPublicLink(
|
||||||
|
doc: Partial<DriveFile> & { id: string },
|
||||||
|
accessLevel: publicAccessLevel,
|
||||||
|
) {
|
||||||
return await this.updateDocument(doc.id, {
|
return await this.updateDocument(doc.id, {
|
||||||
...doc,
|
...doc,
|
||||||
access_info: {
|
access_info: {
|
||||||
...doc.access_info!,
|
...doc.access_info!,
|
||||||
public: {
|
public: {
|
||||||
...doc.access_info?.public!,
|
...doc.access_info?.public!,
|
||||||
level: accessLevel
|
level: accessLevel,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async shareWithPublicLinkWithOkCheck(doc: Partial<DriveFile> & { id: string }, accessLevel: publicAccessLevel) {
|
async shareWithPublicLinkWithOkCheck(
|
||||||
const shareResponse = await this.shareWithPublicLink(doc, accessLevel);
|
doc: Partial<DriveFile> & { id: string },
|
||||||
|
accessLevel: publicAccessLevel,
|
||||||
|
) {
|
||||||
|
const shareResponse = await this.shareWithPublicLink(doc, accessLevel);
|
||||||
expect(shareResponse.statusCode).toBe(200);
|
expect(shareResponse.statusCode).toBe(200);
|
||||||
return deserialize<DriveFile>(DriveFile, shareResponse.body);
|
return deserialize<DriveFile>(DriveFile, shareResponse.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
async shareWithPermissions(doc: Partial<DriveFile> & { id: string }, toUserId: string, permissions: DriveFileAccessLevel) {
|
async shareWithPermissions(
|
||||||
|
doc: Partial<DriveFile> & { id: string },
|
||||||
|
toUserId: string,
|
||||||
|
permissions: DriveFileAccessLevel,
|
||||||
|
) {
|
||||||
doc.access_info.entities.push({
|
doc.access_info.entities.push({
|
||||||
type: "user",
|
type: "user",
|
||||||
id: toUserId,
|
id: toUserId,
|
||||||
@@ -316,21 +363,19 @@ export default class UserApi {
|
|||||||
payload: {
|
payload: {
|
||||||
company_id: doc.company_id,
|
company_id: doc.company_id,
|
||||||
document_id: doc.id,
|
document_id: doc.id,
|
||||||
token: doc.access_info.public?.token
|
token: doc.access_info.public?.token,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
const { access_token } = deserialize<AccessTokenMockClass>(
|
const { access_token } = deserialize<AccessTokenMockClass>(
|
||||||
AccessTokenMockClass,
|
AccessTokenMockClass,
|
||||||
accessRes.body
|
accessRes.body,
|
||||||
);
|
);
|
||||||
expect(access_token).toBeDefined();
|
expect(access_token).toBeDefined();
|
||||||
|
|
||||||
return access_token;
|
return access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRandomDocument(
|
async createRandomDocument(parent_id = "root") {
|
||||||
parent_id = "root"
|
|
||||||
) {
|
|
||||||
const file = await this.uploadRandomFile();
|
const file = await this.uploadRandomFile();
|
||||||
|
|
||||||
const doc = await this.createDocumentFromFile(file, parent_id);
|
const doc = await this.createDocumentFromFile(file, parent_id);
|
||||||
@@ -340,7 +385,7 @@ export default class UserApi {
|
|||||||
expect(doc.parent_id).toEqual(parent_id);
|
expect(doc.parent_id).toEqual(parent_id);
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
};
|
}
|
||||||
|
|
||||||
async createDocumentFromFilename(
|
async createDocumentFromFilename(
|
||||||
file_name:
|
file_name:
|
||||||
@@ -361,16 +406,13 @@ export default class UserApi {
|
|||||||
expect(doc.parent_id).toEqual(parent_id);
|
expect(doc.parent_id).toEqual(parent_id);
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
};
|
}
|
||||||
|
|
||||||
async createDocumentFromFile(
|
async createDocumentFromFile(file: File, parent_id = "root") {
|
||||||
file: File,
|
|
||||||
parent_id = "root"
|
|
||||||
) {
|
|
||||||
const item = {
|
const item = {
|
||||||
name: file.metadata.name,
|
name: file.metadata.name,
|
||||||
parent_id: parent_id,
|
parent_id: parent_id,
|
||||||
company_id: file.company_id
|
company_id: file.company_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const version = {
|
const version = {
|
||||||
@@ -378,35 +420,31 @@ export default class UserApi {
|
|||||||
name: file.metadata.name,
|
name: file.metadata.name,
|
||||||
size: file.upload_data?.size,
|
size: file.upload_data?.size,
|
||||||
thumbnails: [],
|
thumbnails: [],
|
||||||
external_id: file.id
|
external_id: file.id,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return await this.createDocument(item, version);
|
return await this.createDocument(item, version);
|
||||||
};
|
}
|
||||||
|
|
||||||
async updateDocument(
|
async updateDocument(id: string | "root" | "trash" | "shared_with_me", item: Partial<DriveFile>) {
|
||||||
id: string | "root" | "trash" | "shared_with_me",
|
|
||||||
item: Partial<DriveFile>
|
|
||||||
) {
|
|
||||||
return await this.api.post(
|
return await this.api.post(
|
||||||
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
||||||
item,
|
item,
|
||||||
{
|
{
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
});
|
},
|
||||||
};
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async beginEditingDocument(
|
async beginEditingDocument(driveFileId: string, editorApplicationId: string): Promise<Response> {
|
||||||
driveFileId: string,
|
|
||||||
editorApplicationId: string,
|
|
||||||
): Promise<Response> {
|
|
||||||
return await this.api.post(
|
return await this.api.post(
|
||||||
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${driveFileId}/editing_session`,
|
`${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${driveFileId}/editing_session`,
|
||||||
{ editorApplicationId },
|
{ editorApplicationId },
|
||||||
{
|
{
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEditingDocument(
|
async updateEditingDocument(
|
||||||
@@ -415,7 +453,7 @@ export default class UserApi {
|
|||||||
userId: string | null = null,
|
userId: string | null = null,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const fullPath = `${__dirname}/assets/${UserApi.ALL_FILES[0]}`;
|
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 });
|
const form = formAutoContent({ file: readable });
|
||||||
form.headers["authorization"] = `Bearer ${this.jwt}`;
|
form.headers["authorization"] = `Bearer ${this.jwt}`;
|
||||||
let queryString = keepEditing ? "keepEditing=true" : "";
|
let queryString = keepEditing ? "keepEditing=true" : "";
|
||||||
@@ -423,23 +461,23 @@ export default class UserApi {
|
|||||||
queryString += `${queryString.length ? "&" : ""}userId=${encodeURIComponent(userId)}`;
|
queryString += `${queryString.length ? "&" : ""}userId=${encodeURIComponent(userId)}`;
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editingSessionKey)}${queryString ? "?" : ""}${queryString}`,
|
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editingSessionKey)}${
|
||||||
|
queryString ? "?" : ""
|
||||||
|
}${queryString}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
},
|
},
|
||||||
...form,
|
...form,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelEditingDocument(
|
async cancelEditingDocument(editingSessionKey: string): Promise<Response> {
|
||||||
editingSessionKey: string,
|
|
||||||
): Promise<Response> {
|
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `${UserApi.DOC_URL}/editing_session/${editingSessionKey}`,
|
url: `${UserApi.DOC_URL}/editing_session/${editingSessionKey}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,65 +487,56 @@ export default class UserApi {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const result = await this.beginEditingDocument(driveFileId, editorApplicationId);
|
const result = await this.beginEditingDocument(driveFileId, editorApplicationId);
|
||||||
expect(result.statusCode).toBe(200);
|
expect(result.statusCode).toBe(200);
|
||||||
const {editingSessionKey} = result.json();
|
const { editingSessionKey } = result.json();
|
||||||
expect(editingSessionKey).toBeTruthy();
|
expect(editingSessionKey).toBeTruthy();
|
||||||
return editingSessionKey;
|
return editingSessionKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchDocument(
|
async searchDocument(payload: Record<string, any>) {
|
||||||
payload: Record<string, any>
|
|
||||||
) {
|
|
||||||
const response = await this.platform.app.inject({
|
const response = await this.platform.app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/search`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/search`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
},
|
},
|
||||||
payload
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
return deserialize<SearchResultMockClass>(
|
return deserialize<SearchResultMockClass>(SearchResultMockClass, response.body);
|
||||||
SearchResultMockClass,
|
}
|
||||||
response.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
async browseDocuments(
|
async browseDocuments(id: string, payload: Record<string, any> = {}) {
|
||||||
id: string,
|
|
||||||
payload: Record<string, any> = {}
|
|
||||||
) {
|
|
||||||
const response = await this.platform.app.inject({
|
const response = await this.platform.app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/${id}`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/${id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
},
|
},
|
||||||
payload
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
return deserialize<DriveItemDetailsMockClass>(
|
return deserialize<DriveItemDetailsMockClass>(DriveItemDetailsMockClass, response.body);
|
||||||
DriveItemDetailsMockClass,
|
}
|
||||||
response.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
async getDocument(id: string | "root" | "trash" | "shared_with_me") {
|
async getDocument(id: string | "root" | "trash" | "shared_with_me") {
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
async zipDocument(id: string | "root" | "trash" | "shared_with_me") {
|
async zipDocument(id: string | "root" | "trash" | "shared_with_me") {
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/download/zip?items=${id}`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/download/zip?items=${id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
async getDocumentOKCheck(id: string | "root" | "trash" | "shared_with_me") {
|
async getDocumentOKCheck(id: string | "root" | "trash" | "shared_with_me") {
|
||||||
const response = await this.getDocument(id);
|
const response = await this.getDocument(id);
|
||||||
@@ -515,42 +544,38 @@ export default class UserApi {
|
|||||||
const doc = deserialize<DriveItemDetailsMockClass>(DriveItemDetailsMockClass, response.body);
|
const doc = deserialize<DriveItemDetailsMockClass>(DriveItemDetailsMockClass, response.body);
|
||||||
expect(doc.item?.id).toBe(id);
|
expect(doc.item?.id).toBe(id);
|
||||||
return doc;
|
return doc;
|
||||||
};
|
}
|
||||||
|
|
||||||
async getDocumentByEditingKey(editing_session_key: string) {
|
async getDocumentByEditingKey(editing_session_key: string) {
|
||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editing_session_key)}`,
|
url: `${UserApi.DOC_URL}/editing_session/${encodeURIComponent(editing_session_key)}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
async sharedWithMeDocuments(
|
async sharedWithMeDocuments(payload: Record<string, any>) {
|
||||||
payload: Record<string, any>
|
|
||||||
) {
|
|
||||||
const response = await this.platform.app.inject({
|
const response = await this.platform.app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/shared_with_me`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/browse/shared_with_me`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${this.jwt}`
|
authorization: `Bearer ${this.jwt}`,
|
||||||
},
|
},
|
||||||
payload
|
payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
return deserialize<DriveItemDetailsMockClass>(
|
return deserialize<DriveItemDetailsMockClass>(DriveItemDetailsMockClass, response.body);
|
||||||
DriveItemDetailsMockClass,
|
}
|
||||||
response.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
async quota() {
|
async quota() {
|
||||||
const url = "/internal/services/users/v1/users";
|
const url = "/internal/services/users/v1/users";
|
||||||
|
|
||||||
const response = await this.platform.app.inject({
|
const response = await this.platform.app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { "authorization": `Bearer ${this.jwt}` },
|
headers: { authorization: `Bearer ${this.jwt}` },
|
||||||
url: `${url}/${this.user.id}/quota?companyId=${this.platform.workspace.company_id}`
|
url: `${url}/${this.user.id}/quota?companyId=${this.platform.workspace.company_id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return deserialize<UserQuota>(UserQuotaMockClass, response.body);
|
return deserialize<UserQuota>(UserQuotaMockClass, response.body);
|
||||||
@@ -560,7 +585,7 @@ export default class UserApi {
|
|||||||
return await this.platform.app.inject({
|
return await this.platform.app.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${id}`,
|
||||||
headers: { "authorization": `Bearer ${this.jwt}` },
|
headers: { authorization: `Bearer ${this.jwt}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ describe("The Documents Browser Window and API", () => {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await currentUser.shareWithPermissions(file, anotherUser.user.id, "read");
|
await currentUser.shareWithPermissions(file, anotherUser.user.id, "read");
|
||||||
}
|
}
|
||||||
|
// for opensearch to index the files
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -147,6 +149,7 @@ describe("The Documents Browser Window and API", () => {
|
|||||||
it("Should paginate shared with me ", async () => {
|
it("Should paginate shared with me ", async () => {
|
||||||
let page_token: any = "1";
|
let page_token: any = "1";
|
||||||
const limitStr = "2";
|
const limitStr = "2";
|
||||||
|
|
||||||
let docs = await anotherUser.browseDocuments(sharedWIthMeFolder, {
|
let docs = await anotherUser.browseDocuments(sharedWIthMeFolder, {
|
||||||
paginate: { page_token, limitStr },
|
paginate: { page_token, limitStr },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz"
|
||||||
integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==
|
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:
|
class-transformer@^0.3.1:
|
||||||
version "0.3.1"
|
version "0.3.1"
|
||||||
resolved "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz"
|
resolved "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz"
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ services:
|
|||||||
- 27017:27017
|
- 27017:27017
|
||||||
networks:
|
networks:
|
||||||
- tdrive_network
|
- tdrive_network
|
||||||
|
|
||||||
|
av:
|
||||||
|
image: clamav/clamav:latest
|
||||||
|
container_name: av
|
||||||
|
ports:
|
||||||
|
- 3310:3310
|
||||||
|
networks:
|
||||||
|
- tdrive_network
|
||||||
|
|
||||||
node:
|
node:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -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
|
# 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:
|
services:
|
||||||
|
|
||||||
opensearch-node1:
|
opensearch-node1:
|
||||||
image: opensearchproject/opensearch:2.11.0 # Specifying the latest available image - modify if you want a specific version
|
image: opensearchproject/opensearch:2.11.0 # Specifying the latest available image - modify if you want a specific version
|
||||||
container_name: opensearch-node1
|
container_name: opensearch-node1
|
||||||
@@ -13,7 +12,7 @@ services:
|
|||||||
- discovery.seed_hosts=opensearch-node1 # Nodes to look for when discovering the cluster
|
- 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
|
- cluster.initial_cluster_manager_nodes=opensearch-node1 # Nodes eligible to serve as cluster manager
|
||||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping
|
- 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
|
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # Set min and max JVM heap sizes to at least 50% of system RAM
|
||||||
ulimits:
|
ulimits:
|
||||||
memlock:
|
memlock:
|
||||||
@@ -22,13 +21,13 @@ services:
|
|||||||
nofile:
|
nofile:
|
||||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536
|
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536
|
||||||
hard: 65536
|
hard: 65536
|
||||||
# volumes:
|
# volumes:
|
||||||
# - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container
|
# - opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container
|
||||||
ports:
|
ports:
|
||||||
- 9200:9200 # REST API
|
- 9200:9200 # REST API
|
||||||
# - 9600:9600 # Performance Analyzer
|
# - 9600:9600 # Performance Analyzer
|
||||||
# networks:
|
# networks:
|
||||||
# - opensearch-net # All of the containers will join the same Docker bridge network
|
# - opensearch-net # All of the containers will join the same Docker bridge network
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres
|
||||||
@@ -40,6 +39,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
|
av:
|
||||||
|
image: clamav/clamav:latest
|
||||||
|
container_name: av
|
||||||
|
ports:
|
||||||
|
- 3310:3310
|
||||||
|
|
||||||
node:
|
node:
|
||||||
# Use the build context in the current directory
|
# Use the build context in the current directory
|
||||||
build:
|
build:
|
||||||
@@ -66,6 +71,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- opensearch-node1
|
- opensearch-node1
|
||||||
|
- av
|
||||||
links:
|
links:
|
||||||
- postgres
|
- postgres
|
||||||
- opensearch-node1
|
- opensearch-node1
|
||||||
|
- av
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
version: "3.4"
|
version: "3.4"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ services:
|
|||||||
- 27017:27017
|
- 27017:27017
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
|
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:
|
redis:
|
||||||
image: "redis:alpine"
|
image: "redis:alpine"
|
||||||
@@ -79,6 +89,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
av:
|
||||||
|
condition: service_healthy
|
||||||
# rabbitmq:
|
# rabbitmq:
|
||||||
# condition: service_started
|
# condition: service_started
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
@@ -87,6 +99,7 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
links:
|
links:
|
||||||
- mongo
|
- mongo
|
||||||
|
- av
|
||||||
# - rabbitmq
|
# - rabbitmq
|
||||||
|
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"components.item_context_menu.move.modal_header": "Move",
|
"components.item_context_menu.move.modal_header": "Move",
|
||||||
"components.item_context_menu.move_multiple": "Move selected items",
|
"components.item_context_menu.move_multiple": "Move selected items",
|
||||||
"components.item_context_menu.move_multiple.modal_header": "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.move_to_trash": "Delete",
|
||||||
"components.item_context_menu.open_new_window": "Open in new window",
|
"components.item_context_menu.open_new_window": "Open in new window",
|
||||||
"components.item_context_menu.preview": "Preview",
|
"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_restore_file": "Unable to restore this item.",
|
||||||
"hooks.use-drive-actions.unable_update_file": "Unable to update this file.",
|
"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.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.create_account": "Create account",
|
||||||
"login.login_error": "Error during login",
|
"login.login_error": "Error during login",
|
||||||
"molecules.download_banner.download_button": "Download desktop app",
|
"molecules.download_banner.download_button": "Download desktop app",
|
||||||
@@ -211,6 +214,10 @@
|
|||||||
"scenes.app.drive.folders": "Folders",
|
"scenes.app.drive.folders": "Folders",
|
||||||
"scenes.app.drive.nothing": "Nothing here.",
|
"scenes.app.drive.nothing": "Nothing here.",
|
||||||
"scenes.app.drive.used": "used in this folder",
|
"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": "You are disconnected",
|
||||||
"scenes.app.header.disconnected.reload": "Reload",
|
"scenes.app.header.disconnected.reload": "Reload",
|
||||||
"scenes.app.mainview.create_account": "Create your workspace for free on ",
|
"scenes.app.mainview.create_account": "Create your workspace for free on ",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"components.item_context_menu.move.modal_header": "Déplacer",
|
"components.item_context_menu.move.modal_header": "Déplacer",
|
||||||
"components.item_context_menu.move_multiple": "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.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.move_to_trash": "Supprimer",
|
||||||
"components.item_context_menu.open_new_window": "Ouvrir dans une nouvelle fenêtre",
|
"components.item_context_menu.open_new_window": "Ouvrir dans une nouvelle fenêtre",
|
||||||
"components.item_context_menu.preview": "Aperçu",
|
"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_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.unable_update_file": "Impossible de mettre à jour ce fichier",
|
||||||
"hooks.use-drive-actions.update_caused_a_rename": "Renommé en '{{$2}}'.",
|
"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.create_account": "Créer un compte",
|
||||||
"login.login_error": "Erreur lors de la connexion",
|
"login.login_error": "Erreur lors de la connexion",
|
||||||
"molecules.download_banner.download_button": "Télécharger l'application de bureau",
|
"molecules.download_banner.download_button": "Télécharger l'application de bureau",
|
||||||
@@ -204,6 +207,10 @@
|
|||||||
"scenes.app.drive.folders": "Dossiers",
|
"scenes.app.drive.folders": "Dossiers",
|
||||||
"scenes.app.drive.nothing": "Il n'y a rien ici.",
|
"scenes.app.drive.nothing": "Il n'y a rien ici.",
|
||||||
"scenes.app.drive.used": "utilisés dans ce dossier",
|
"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": "Vous êtes déconnecté",
|
||||||
"scenes.app.header.disconnected.reload": "Recharger",
|
"scenes.app.header.disconnected.reload": "Recharger",
|
||||||
"scenes.app.mainview.create_account": "Créez votre espace de travail gratuitement sur ",
|
"scenes.app.mainview.create_account": "Créez votre espace de travail gratuitement sur ",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"components.item_context_menu.move.modal_header": "Переместить",
|
"components.item_context_menu.move.modal_header": "Переместить",
|
||||||
"components.item_context_menu.move_multiple": "Переместить все",
|
"components.item_context_menu.move_multiple": "Переместить все",
|
||||||
"components.item_context_menu.move_multiple.modal_header": "Переместить выбранные елементы",
|
"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.move_to_trash": "Удалить",
|
||||||
"components.item_context_menu.open_new_window": "Открыть в новом окне",
|
"components.item_context_menu.open_new_window": "Открыть в новом окне",
|
||||||
"components.item_context_menu.preview": "Просмотр",
|
"components.item_context_menu.preview": "Просмотр",
|
||||||
@@ -198,6 +199,8 @@
|
|||||||
"hooks.use-drive-actions.unable_restore_file": "Невозможно восстановить эти файлы.",
|
"hooks.use-drive-actions.unable_restore_file": "Невозможно восстановить эти файлы.",
|
||||||
"hooks.use-drive-actions.unable_update_file": "Невозможно обновить эти файлы.",
|
"hooks.use-drive-actions.unable_update_file": "Невозможно обновить эти файлы.",
|
||||||
"hooks.use-drive-actions.update_caused_a_rename": "Элемент был переименован в «{{$2}}».",
|
"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.create_account": "Создать учетную запись",
|
||||||
"login.login_error": "Ошибка при входе в систему",
|
"login.login_error": "Ошибка при входе в систему",
|
||||||
"molecules.download_banner.download_button": "Скачать настольное приложение",
|
"molecules.download_banner.download_button": "Скачать настольное приложение",
|
||||||
@@ -211,6 +214,10 @@
|
|||||||
"scenes.app.drive.folders": "Папки",
|
"scenes.app.drive.folders": "Папки",
|
||||||
"scenes.app.drive.nothing": "Здесь ничего нет.",
|
"scenes.app.drive.nothing": "Здесь ничего нет.",
|
||||||
"scenes.app.drive.used": "использовано в этой папке",
|
"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": "Соединение отсутствует",
|
||||||
"scenes.app.header.disconnected.reload": "Перезагрузить",
|
"scenes.app.header.disconnected.reload": "Перезагрузить",
|
||||||
"scenes.app.mainview.create_account": "Создайте свою рабочую среду бесплатно в ",
|
"scenes.app.mainview.create_account": "Создайте свою рабочую среду бесплатно в ",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"components.item_context_menu.move.modal_header": "Di chuyển",
|
"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": "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.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.move_to_trash": "Xóa",
|
||||||
"components.item_context_menu.open_new_window": "Mở trong cửa sổ mới",
|
"components.item_context_menu.open_new_window": "Mở trong cửa sổ mới",
|
||||||
"components.item_context_menu.preview": "Xem trước",
|
"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_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.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.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.create_account": "Tạo tài khoản",
|
||||||
"login.login_error": "Lỗi trong khi đăng nhập",
|
"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",
|
"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.folders": "Thư mục",
|
||||||
"scenes.app.drive.nothing": "Không có gì ở đây.",
|
"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.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": "Bạn đang ngoại tuyến",
|
||||||
"scenes.app.header.disconnected.reload": "Tải lại",
|
"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 ",
|
"scenes.app.mainview.create_account": "Tạo không gian làm việc của bạn miễn phí trên ",
|
||||||
|
|||||||
@@ -135,6 +135,20 @@ export class DriveApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async checkMalware(companyId: string, id: string) {
|
||||||
|
return await Api.post<any, DriveItem>(
|
||||||
|
`/internal/services/documents/v1/companies/${companyId}/item/${id}/check_malware${appendTdriveToken()}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async reScan(companyId: string, id: string) {
|
||||||
|
return await Api.post<any, DriveItem>(
|
||||||
|
`/internal/services/documents/v1/companies/${companyId}/item/${id}/rescan${appendTdriveToken()}`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static getDownloadUrl(companyId: string, id: string, versionId?: string) {
|
static getDownloadUrl(companyId: string, id: string, versionId?: string) {
|
||||||
if (versionId)
|
if (versionId)
|
||||||
return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}`);
|
return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}`);
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import useRouterCompany from '@features/router/hooks/use-router-company';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil';
|
import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil';
|
||||||
import { DriveApiClient } from '../api-client/api-client';
|
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 { BrowseFilter, DriveItem, DriveItemVersion } from '../types';
|
||||||
import { SharedWithMeFilterState } from '../state/shared-with-me-filter';
|
import { SharedWithMeFilterState } from '../state/shared-with-me-filter';
|
||||||
import Languages from 'features/global/services/languages-service';
|
import Languages from 'features/global/services/languages-service';
|
||||||
import { useUserQuota } from 'features/users/hooks/use-user-quota';
|
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 the children of a drive item
|
||||||
* @returns
|
* @returns
|
||||||
@@ -19,6 +27,7 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
const sortItem = useRecoilValue(DriveItemSort);
|
const sortItem = useRecoilValue(DriveItemSort);
|
||||||
const [ paginateItem ] = useRecoilState(DriveItemPagination);
|
const [ paginateItem ] = useRecoilState(DriveItemPagination);
|
||||||
const { getQuota } = useUserQuota();
|
const { getQuota } = useUserQuota();
|
||||||
|
const AVEnabled = FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED);
|
||||||
|
|
||||||
const refresh = useRecoilCallback(
|
const refresh = useRecoilCallback(
|
||||||
({ set, snapshot }) =>
|
({ set, snapshot }) =>
|
||||||
@@ -35,7 +44,13 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
set(DriveItemPagination, pagination);
|
set(DriveItemPagination, pagination);
|
||||||
}
|
}
|
||||||
try {
|
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(DriveItemChildrenAtom(parentId), details.children);
|
||||||
set(DriveItemAtom(parentId), details);
|
set(DriveItemAtom(parentId), details);
|
||||||
for (const child of details.children) {
|
for (const child of details.children) {
|
||||||
@@ -87,10 +102,31 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const download = useCallback(
|
const download = useCallback(
|
||||||
async (id: string, versionId?: string) => {
|
async (id: string, isMalicious = false, versionId?: string) => {
|
||||||
try {
|
try {
|
||||||
const url = DriveApiClient.getDownloadUrl(companyId, id, versionId);
|
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) {
|
} catch (e) {
|
||||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
||||||
}
|
}
|
||||||
@@ -99,10 +135,34 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const downloadZip = useCallback(
|
const downloadZip = useCallback(
|
||||||
async (ids: string[], isDirectory = false) => {
|
async (ids: string[], isDirectory = false, containsMalicious = false) => {
|
||||||
try {
|
try {
|
||||||
const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory);
|
const triggerDownload = async () => {
|
||||||
(window as any).open(url, '_blank').focus();
|
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) {
|
} catch (e) {
|
||||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
||||||
}
|
}
|
||||||
@@ -142,7 +202,12 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
try {
|
try {
|
||||||
const newItem = await DriveApiClient.update(companyId, id, update);
|
const newItem = await DriveApiClient.update(companyId, id, update);
|
||||||
if (previousName && previousName !== newItem.name && !update.name)
|
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);
|
await refresh(id || '', true);
|
||||||
if (!inPublicSharing) await refresh(parentId || '', true);
|
if (!inPublicSharing) await refresh(parentId || '', true);
|
||||||
if (update?.parent_id !== parentId) await refresh(update?.parent_id || '', true);
|
if (update?.parent_id !== parentId) await refresh(update?.parent_id || '', true);
|
||||||
@@ -183,12 +248,47 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
|||||||
parentId,
|
parentId,
|
||||||
filter,
|
filter,
|
||||||
sortItem,
|
sortItem,
|
||||||
pagination
|
pagination,
|
||||||
);
|
);
|
||||||
return details;
|
return details;
|
||||||
},
|
},
|
||||||
[paginateItem, refresh],
|
[paginateItem, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const checkMalware = useCallback(
|
||||||
|
async (item: Partial<DriveItem>) => {
|
||||||
|
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<DriveItem>) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export type DriveItem = {
|
|||||||
|
|
||||||
size: number;
|
size: number;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
av_status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DriveFileAccessLevelForInherited = 'none' | 'manage';
|
export type DriveFileAccessLevelForInherited = 'none' | 'manage';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum FeatureNames {
|
|||||||
COMPANY_DISPLAY_EMAIL = 'company:display_email',
|
COMPANY_DISPLAY_EMAIL = 'company:display_email',
|
||||||
COMPANY_USER_QUOTA = 'company:user_quota',
|
COMPANY_USER_QUOTA = 'company:user_quota',
|
||||||
COMPANY_MANAGE_ACCESS = 'company:managed_access',
|
COMPANY_MANAGE_ACCESS = 'company:managed_access',
|
||||||
|
COMPANY_AV_ENABLED = 'company:av_enabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureValueType = boolean | number;
|
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_DISPLAY_EMAIL, true);
|
||||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_USER_QUOTA, false);
|
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_USER_QUOTA, false);
|
||||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_MANAGE_ACCESS, true);
|
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
|
* ChannelServiceImpl that allow you to manage feature flipping in Tdrive using react feature toggles
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const useOnBuildContextMenu = (
|
|||||||
DriveCurrentFolderAtom({ initialFolderId: initialParentId || 'root' }),
|
DriveCurrentFolderAtom({ initialFolderId: initialParentId || 'root' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { download, downloadZip, update, restore } = useDriveActions();
|
const { download, downloadZip, update, restore, reScan } = useDriveActions();
|
||||||
const setCreationModalState = useSetRecoilState(CreateModalAtom);
|
const setCreationModalState = useSetRecoilState(CreateModalAtom);
|
||||||
const setUploadModalState = useSetRecoilState(UploadModelAtom);
|
const setUploadModalState = useSetRecoilState(UploadModelAtom);
|
||||||
const setSelectorModalState = useSetRecoilState(SelectorModalAtom);
|
const setSelectorModalState = useSetRecoilState(SelectorModalAtom);
|
||||||
@@ -66,6 +66,8 @@ export const useOnBuildContextMenu = (
|
|||||||
const inTrash = parent.path?.[0]?.id.includes('trash') || viewId?.includes('trash');
|
const inTrash = parent.path?.[0]?.id.includes('trash') || viewId?.includes('trash');
|
||||||
const isPersonal = item?.scope === 'personal';
|
const isPersonal = item?.scope === 'personal';
|
||||||
const selectedCount = checked.length;
|
const selectedCount = checked.length;
|
||||||
|
const notSafe =
|
||||||
|
!item?.is_directory && !['uploaded', 'safe'].includes(item?.av_status || '');
|
||||||
|
|
||||||
let menu: any[] = [];
|
let menu: any[] = [];
|
||||||
|
|
||||||
@@ -75,26 +77,41 @@ export const useOnBuildContextMenu = (
|
|||||||
const access = upToDateItem.access || 'none';
|
const access = upToDateItem.access || 'none';
|
||||||
const hideShareItem = access === 'read' || getPublicLinkToken() || inTrash;
|
const hideShareItem = access === 'read' || getPublicLinkToken() || inTrash;
|
||||||
const hideManageAccessItem =
|
const hideManageAccessItem =
|
||||||
access === 'read'
|
access === 'read' ||
|
||||||
|| getPublicLinkToken()
|
getPublicLinkToken() ||
|
||||||
|| inTrash
|
inTrash ||
|
||||||
|| !FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS);
|
!FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS);
|
||||||
const newMenuActions = [
|
const newMenuActions = [
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'share-alt',
|
icon: 'share-alt',
|
||||||
text: Languages.t('components.item_context_menu.share'),
|
text: Languages.t('components.item_context_menu.share'),
|
||||||
hide: hideShareItem,
|
hide: hideShareItem || notSafe,
|
||||||
onClick: () => setPublicLinkModalState({ open: true, id: item.id }),
|
onClick: () => setPublicLinkModalState({ open: true, id: item.id }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'users-alt',
|
icon: 'users-alt',
|
||||||
text: Languages.t('components.item_context_menu.manage_access'),
|
text: Languages.t('components.item_context_menu.manage_access'),
|
||||||
hide: hideManageAccessItem,
|
hide: hideManageAccessItem || notSafe,
|
||||||
onClick: () => setAccessModalState({ open: true, id: item.id }),
|
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',
|
type: 'menu',
|
||||||
icon: 'download-alt',
|
icon: 'download-alt',
|
||||||
@@ -104,7 +121,7 @@ export const useOnBuildContextMenu = (
|
|||||||
downloadZip([item!.id]);
|
downloadZip([item!.id]);
|
||||||
console.log(item!.id);
|
console.log(item!.id);
|
||||||
} else {
|
} else {
|
||||||
download(item.id);
|
download(item.id, notSafe);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -120,12 +137,12 @@ export const useOnBuildContextMenu = (
|
|||||||
window.open(route, '_blank');
|
window.open(route, '_blank');
|
||||||
}
|
}
|
||||||
}, // */
|
}, // */
|
||||||
{ type: 'separator' },
|
{ type: 'separator', hide: notSafe },
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'folder-question',
|
icon: 'folder-question',
|
||||||
text: Languages.t('components.item_context_menu.move'),
|
text: Languages.t('components.item_context_menu.move'),
|
||||||
hide: access === 'read' || inTrash || inPublicSharing,
|
hide: access === 'read' || inTrash || inPublicSharing || notSafe,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
setSelectorModalState({
|
setSelectorModalState({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -150,7 +167,7 @@ export const useOnBuildContextMenu = (
|
|||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'file-edit-alt',
|
icon: 'file-edit-alt',
|
||||||
text: Languages.t('components.item_context_menu.rename'),
|
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 }),
|
onClick: () => setPropertiesModalState({ open: true, id: item.id, inPublicSharing }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,7 +177,8 @@ export const useOnBuildContextMenu = (
|
|||||||
hide:
|
hide:
|
||||||
!item.access_info.public?.level ||
|
!item.access_info.public?.level ||
|
||||||
item.access_info.public?.level === 'none' ||
|
item.access_info.public?.level === 'none' ||
|
||||||
inTrash,
|
inTrash ||
|
||||||
|
notSafe,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
copyToClipboard(getPublicLink(item || parent?.item));
|
copyToClipboard(getPublicLink(item || parent?.item));
|
||||||
ToasterService.success(
|
ToasterService.success(
|
||||||
@@ -172,10 +190,10 @@ export const useOnBuildContextMenu = (
|
|||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'history',
|
icon: 'history',
|
||||||
text: Languages.t('components.item_context_menu.versions'),
|
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 }),
|
onClick: () => setVersionModal({ open: true, id: item.id }),
|
||||||
},
|
},
|
||||||
{ type: 'separator', hide: access !== 'manage' || inTrash },
|
{ type: 'separator', hide: access !== 'manage' || inTrash || notSafe },
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
icon: 'trash',
|
icon: 'trash',
|
||||||
@@ -238,15 +256,25 @@ export const useOnBuildContextMenu = (
|
|||||||
type: 'menu',
|
type: 'menu',
|
||||||
text: Languages.t('components.item_context_menu.download_multiple'),
|
text: Languages.t('components.item_context_menu.download_multiple'),
|
||||||
hide: inTrash,
|
hide: inTrash,
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
selectedCount === 1 ? download(checked[0].id) : downloadZip(checked.map(c => c.id)),
|
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',
|
type: 'menu',
|
||||||
text: Languages.t('components.item_context_menu.clear_selection'),
|
text: Languages.t('components.item_context_menu.clear_selection'),
|
||||||
onClick: () => setChecked({}),
|
onClick: () => setChecked({}),
|
||||||
},
|
},
|
||||||
{ type: 'separator', hide: parent.access === 'read' },
|
{ type: 'separator', hide: parent.access === 'read' || notSafe },
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
text: Languages.t('components.item_context_menu.delete_multiple'),
|
text: Languages.t('components.item_context_menu.delete_multiple'),
|
||||||
@@ -517,7 +545,9 @@ export const useOnBuildFileContextMenu = () => {
|
|||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
text: Languages.t('components.item_context_menu.download'),
|
text: Languages.t('components.item_context_menu.download'),
|
||||||
onClick: () => download(item.id),
|
onClick: () => {
|
||||||
|
download(item.id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return menuItems;
|
return menuItems;
|
||||||
|
|||||||
@@ -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 { Button } from '@atoms/button/button';
|
||||||
import { Base, BaseSmall } from '@atoms/text';
|
import { Base, BaseSmall } from '@atoms/text';
|
||||||
import Menu from '@components/menus/menu';
|
import Menu from '@components/menus/menu';
|
||||||
import useRouterCompany from '@features/router/hooks/use-router-company';
|
import useRouterCompany from '@features/router/hooks/use-router-company';
|
||||||
import { useDrivePreview } from '@features/drive/hooks/use-drive-preview';
|
import { useDrivePreview } from '@features/drive/hooks/use-drive-preview';
|
||||||
import { formatBytes } from '@features/drive/utils';
|
import { formatBytes } from '@features/drive/utils';
|
||||||
|
import Languages from '@features/global/services/languages-service';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PublicIcon } from '../components/public-icon';
|
import { PublicIcon } from '../components/public-icon';
|
||||||
import { CheckableIcon, DriveItemOverlayProps, DriveItemProps } from './common';
|
import { CheckableIcon, DriveItemOverlayProps, DriveItemProps } from './common';
|
||||||
|
|
||||||
import './style.scss';
|
import './style.scss';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import RouterServices from '@features/router/services/router-service';
|
import RouterServices from '@features/router/services/router-service';
|
||||||
import { DocumentIcon } from './document-icon';
|
import { DocumentIcon } from './document-icon';
|
||||||
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
|
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
|
||||||
import { formatDateShort } from 'app/features/global/utils/Numbers';
|
import { formatDateShort } from 'app/features/global/utils/Numbers';
|
||||||
|
import FeatureTogglesService, {
|
||||||
|
FeatureNames,
|
||||||
|
} from '@features/global/services/feature-toggles-service';
|
||||||
|
|
||||||
export const DocumentRow = ({
|
export const DocumentRow = ({
|
||||||
item,
|
item,
|
||||||
@@ -27,6 +37,7 @@ export const DocumentRow = ({
|
|||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const {open} = useDrivePreview();
|
const {open} = useDrivePreview();
|
||||||
const company = useRouterCompany();
|
const company = useRouterCompany();
|
||||||
|
const notSafe = ['malicious', 'skipped', 'scan_failed'].includes(item.av_status);
|
||||||
|
|
||||||
const preview = () => {
|
const preview = () => {
|
||||||
open(item);
|
open(item);
|
||||||
@@ -39,7 +50,7 @@ export const DocumentRow = ({
|
|||||||
className={
|
className={
|
||||||
'flex flex-row items-center border border-zinc-200 dark:border-zinc-800 px-4 py-3 cursor-pointer ' +
|
'flex flex-row items-center border border-zinc-200 dark:border-zinc-800 px-4 py-3 cursor-pointer ' +
|
||||||
(checked
|
(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 ') +
|
: 'hover:bg-zinc-500 hover:bg-opacity-10 ') +
|
||||||
(className || '')
|
(className || '')
|
||||||
}
|
}
|
||||||
@@ -48,7 +59,9 @@ export const DocumentRow = ({
|
|||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (e.shiftKey || e.ctrlKey) onCheck(!checked);
|
if (e.shiftKey || e.ctrlKey) onCheck(!checked);
|
||||||
else if (onClick) onClick();
|
else if (onClick) onClick();
|
||||||
else preview();
|
else {
|
||||||
|
if (!notSafe) preview();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -76,6 +89,20 @@ export const DocumentRow = ({
|
|||||||
<div className="shrink-0 ml-4 text-right lg:w-24 sm:w-20 ">
|
<div className="shrink-0 ml-4 text-right lg:w-24 sm:w-20 ">
|
||||||
<BaseSmall>{formatBytes(item.size)}</BaseSmall>
|
<BaseSmall>{formatBytes(item.size)}</BaseSmall>
|
||||||
</div>
|
</div>
|
||||||
|
{FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED) && (
|
||||||
|
<div className="shrink-0 ml-4 text-right lg:w-24 sm:w-20 ">
|
||||||
|
<BaseSmall title={Languages.t(`scenes.app.drive.document_row.av_${item?.av_status}`)}>
|
||||||
|
{item?.av_status === 'scanning' && (
|
||||||
|
<ShieldExclamationIcon className="w-5 text-yellow-400" />
|
||||||
|
)}
|
||||||
|
{item?.av_status === 'malicious' && (
|
||||||
|
<ShieldExclamationIcon className="w-5 text-rose-400" />
|
||||||
|
)}
|
||||||
|
{item?.av_status === 'skipped' && <BanIcon className="w-5 text-gray-400" />}
|
||||||
|
{item?.av_status === 'scan_failed' && <BanIcon className="w-5 text-gray-400" />}
|
||||||
|
</BaseSmall>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="shrink-0 ml-4">
|
<div className="shrink-0 ml-4">
|
||||||
<Menu menu={onBuildContextMenu}>
|
<Menu menu={onBuildContextMenu}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
|||||||
if (!item?.last_version_cache) return <></>;
|
if (!item?.last_version_cache) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent title={Languages.t('components.VersionModalContent_version') + " " + item?.name}>
|
<ModalContent title={Languages.t('components.VersionModalContent_version') + ' ' + item?.name}>
|
||||||
<UploadZone
|
<UploadZone
|
||||||
overClassName={'!m-4'}
|
overClassName={'!m-4'}
|
||||||
disableClick
|
disableClick
|
||||||
@@ -66,12 +66,14 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{access !== 'read' && (
|
{access !== 'read' && (
|
||||||
<div className={'flex flex-row items-center bg-zinc-100 dark:bg-zinc-900 rounded-md mb-4 p-4'}>
|
<div
|
||||||
|
className={
|
||||||
|
'flex flex-row items-center bg-zinc-100 dark:bg-zinc-900 rounded-md mb-4 p-4'
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="grow flex items-center">
|
<div className="grow flex items-center">
|
||||||
<Base>
|
<Base>{Languages.t('components.VersionModalContent_version_dec')}</Base>
|
||||||
{Languages.t('components.VersionModalContent_version_dec')}
|
|
||||||
</Base>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 ml-4 flex items-center">
|
<div className="shrink-0 ml-4 flex items-center">
|
||||||
<Button
|
<Button
|
||||||
@@ -108,8 +110,11 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
|||||||
<BaseSmall>{formatBytes(version.file_metadata.size || 0)}</BaseSmall>
|
<BaseSmall>{formatBytes(version.file_metadata.size || 0)}</BaseSmall>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 ml-4">
|
<div className="shrink-0 ml-4">
|
||||||
<Button theme="outline" onClick={() => download(id, version.id)}>
|
<Button
|
||||||
{Languages.t('components.VersionModalContent_donwload')}
|
theme="outline"
|
||||||
|
onClick={() => download(id, item.av_status === 'malicious', version.id)}
|
||||||
|
>
|
||||||
|
{Languages.t('components.VersionModalContent_donwload')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default class ErrorBoundary extends React.Component<PropsType, { hasError
|
|||||||
ErrorBoundary.lastError = {
|
ErrorBoundary.lastError = {
|
||||||
error: {
|
error: {
|
||||||
name: error,
|
name: error,
|
||||||
info: errorInfo.componentStack,
|
info: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
RouterServices.replace(RouterServices.addRedirection(RouterServices.pathnames.ERROR));
|
RouterServices.replace(RouterServices.addRedirection(RouterServices.pathnames.ERROR));
|
||||||
|
|||||||
Reference in New Issue
Block a user