🐞 Add antivirus inside Twake Drive (#725)

🐞 Add antivirus inside Twake Drive (#725)
This commit is contained in:
Montassar Ghanmy
2024-11-19 17:39:18 +01:00
committed by GitHub
parent cc53cd9a94
commit 00f819fb6d
49 changed files with 1341 additions and 257 deletions
@@ -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>
`
]
}) %>
@@ -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>
`
]
}) %>
@@ -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 type DriveScope = "personal" | "shared";
export type AVStatusSafe = "safe";
export type AVStatusUnsafe = "uploaded" | "scanning" | "scan_failed" | "malicious" | "skipped";
export type AVStatus = AVStatusSafe | AVStatusUnsafe;
/**
* This represents an item in the file hierarchy.
*
@@ -32,6 +36,15 @@ export type DriveScope = "personal" | "shared";
* if `scope == "personal"`, otherwise the trash of the shared drive
* - `"trash_$userid"`: Trash folder for a given user (same note as `"user_$userid"`)
* - `"shared_with_me"`: for the feature of the same name
*
* The `status` field represents the current scan status of the file,
* which can be one of the following:
* - `"uploaded"`: The file has been uploaded but not yet scanned.
* - `"scanning"`: The file is currently being scanned.
* - `"scan_failed"`: The scan failed, possibly due to an error.
* - `"safe"`: The file has been scanned and marked as safe.
* - `"malicious"`: The file has been marked as potentially malicious.
* - `"skipped"`: The file scan was skipped (file size too big).
*/
@Entity(TYPE, {
globalIndexes: [
@@ -122,6 +135,10 @@ export class DriveFile {
@Type(() => String)
@Column("scope", "string")
scope: DriveScope;
@Type(() => String)
@Column("av_status", "string")
av_status: AVStatus;
}
const OnlyOfficeSafeDocKeyBase64 = {
@@ -2,7 +2,7 @@ import globalResolver from "../../../global-resolver";
import { logger } from "../../../../core/platform/framework";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import { Initializable } from "../../../../core/platform/framework";
import { DocumentEvents, NotificationPayloadType } from "../../types";
import { DocumentEvents, NotificationPayloadType, eventToTemplateMap } from "../../types";
import { DocumentsProcessor } from "./extract-keywords";
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
import { DriveFile, TYPE } from "../../entities/drive-file";
@@ -17,10 +17,14 @@ export class DocumentsEngine implements Initializable {
id: e.context.company.id,
});
const language = receiver.preferences?.language || "en";
const emailTemplate =
event === DocumentEvents.DOCUMENT_SAHRED
? "notification-document-shared"
: "notification-document-version-updated";
const emailTemplate = eventToTemplateMap[event];
if (!emailTemplate) {
logger.error(`Error dispatching document event. Unknown event type: ${event}`);
return; // Early return on unknown event type
}
try {
const { html, text, subject } = await globalResolver.platformServices.emailPusher.build(
emailTemplate,
@@ -67,6 +71,13 @@ export class DocumentsEngine implements Initializable {
},
);
localEventBus.subscribe(
DocumentEvents.DOCUMENT_AV_SCAN_ALERT,
async (e: NotificationPayloadType) => {
await this.DispatchDocumentEvent(e, DocumentEvents.DOCUMENT_AV_SCAN_ALERT);
},
);
return this;
}
@@ -77,4 +88,8 @@ export class DocumentsEngine implements Initializable {
notifyDocumentVersionUpdated(notificationPayload: NotificationPayloadType) {
localEventBus.publish(DocumentEvents.DOCUMENT_VERSION_UPDATED, notificationPayload);
}
notifyDocumentAVScanAlert(notificationPayload: NotificationPayloadType) {
localEventBus.publish(DocumentEvents.DOCUMENT_AV_SCAN_ALERT, notificationPayload);
}
}
@@ -14,7 +14,7 @@ import { PublicFile } from "../../../services/files/entities/file";
import globalResolver from "../../../services/global-resolver";
import { hasCompanyAdminLevel } from "../../../utils/company";
import gr from "../../global-resolver";
import { DriveFile, EditingSessionKeyFormat, TYPE } from "../entities/drive-file";
import { AVStatus, DriveFile, EditingSessionKeyFormat, TYPE } from "../entities/drive-file";
import { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
import User, { TYPE as UserType } from "../../user/entities/user";
@@ -49,6 +49,7 @@ import {
updateItemSize,
isInTrash,
} from "../utils";
import { getConfigOrDefault } from "../../../utils/get-config";
import {
checkAccess,
getAccessLevel,
@@ -58,7 +59,6 @@ import {
} from "./access-check";
import archiver from "archiver";
import internal from "stream";
import config from "config";
import { MultipartFile } from "@fastify/multipart";
import { UploadOptions } from "src/services/files/types";
import { SortType } from "src/core/platform/services/search/api";
@@ -73,15 +73,9 @@ export class DocumentsService {
userRepository: Repository<User>;
ROOT: RootType = "root";
TRASH: TrashType = "trash";
quotaEnabled: boolean = config.has("drive.featureUserQuota")
? config.get("drive.featureUserQuota")
: false;
defaultQuota: number = config.has("drive.defaultUserQuota")
? config.get("drive.defaultUserQuota")
: 0;
manageAccessEnabled: boolean = config.has("drive.featureManageAccess")
? config.get("drive.featureManageAccess")
: false;
quotaEnabled: boolean = getConfigOrDefault("drive.featureUserQuota", false);
defaultQuota: number = getConfigOrDefault("drive.defaultUserQuota", 0);
manageAccessEnabled: boolean = getConfigOrDefault("drive.featureManageAccess", false);
logger: TdriveLogger = getLogger("Documents Service");
async init(): Promise<this> {
@@ -391,9 +385,8 @@ export class DocumentsService {
throw Error("User does not have access to this item parent");
}
let fileToProcess;
if (file || driveItem.is_directory === false) {
let fileToProcess;
if (file) {
fileToProcess = file;
} else if (driveItemVersion.file_metadata.external_id) {
@@ -484,6 +477,45 @@ export class DocumentsService {
//TODO[ASH] update item size only for files, there is not need to do during direcotry creation
await updateItemSize(driveItem.parent_id, this.repository, context);
// If AV feature is enabled, scan the file
if (globalResolver.services.av?.avEnabled && version) {
try {
driveItem.av_status = await globalResolver.services.av.scanDocument(
driveItem,
driveItemVersion,
async (av_status: AVStatus) => {
// Update the AV status of the file
await this.handleAVStatusUpdate(driveItem, av_status, context);
// Handle preview generation
if (av_status === "safe" && fileToProcess) {
const file = await globalResolver.services.files.generatePreview(
fileToProcess,
{
waitForThumbnail: true,
ignoreThumbnails: false,
},
context,
);
if (file) {
driveItemVersion.file_metadata.thumbnails = file?.thumbnails;
await this.fileVersionRepository.save(driveItemVersion);
driveItem.last_version_cache = driveItemVersion;
}
}
},
context,
);
await this.repository.save(driveItem);
if (driveItem.av_status === "skipped") {
// Notify the user that the document has been skipped
await this.notifyAVScanAlert(driveItem, context);
}
} catch (error) {
this.logger.error(`Error scanning file ${driveItemVersion.file_metadata.external_id}`);
CrudException.throwMe(error, new CrudException("Failed to scan file", 500));
}
}
await globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
"services:documents:process",
{
@@ -545,7 +577,13 @@ export class DocumentsService {
throw Error("content mismatch");
}
const updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"];
let updatable = ["access_info", "name", "tags", "parent_id", "description", "is_in_trash"];
// Check if AV feature is enabled and file is malicious
if (globalResolver.services.av?.avEnabled && item.av_status === "malicious") {
updatable = ["is_in_trash"];
}
let renamedTo: string | undefined;
for (const key of updatable) {
if ((content as any)[key]) {
@@ -864,6 +902,12 @@ export class DocumentsService {
throw new CrudException("User does not have access to this item or its children", 401);
}
// Check if AV feature is enabled and file is malicious
if (globalResolver.services.av?.avEnabled && item.av_status === "malicious") {
this.logger.error("Cannot update a malicious file");
throw new CrudException("Cannot update a malicious file", 403);
}
if (await isInTrash(item, this.repository, context)) {
if (item.is_in_trash != true) {
if (item.scope === "personal") {
@@ -921,6 +965,13 @@ export class DocumentsService {
}
const driveItemVersion = getDefaultDriveItemVersion(version, context);
const fileToProcess = await globalResolver.services.files.getFile(
{
id: driveItemVersion.file_metadata.external_id,
company_id: context.company.id,
},
context,
);
const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context);
// if quota is enabled, check if the user has enough space
@@ -960,6 +1011,45 @@ export class DocumentsService {
await updateItemSize(item.parent_id, this.repository, context);
// If AV feature is enabled, scan the file
if (globalResolver.services.av?.avEnabled && version) {
try {
item.av_status = await globalResolver.services.av.scanDocument(
item,
driveItemVersion,
async (av_status: AVStatus) => {
// Update the AV status of the file
await this.handleAVStatusUpdate(item, av_status, context);
// Handle preview generation
if (av_status === "safe" && fileToProcess) {
const file = await globalResolver.services.files.generatePreview(
fileToProcess,
{
waitForThumbnail: true,
ignoreThumbnails: false,
},
context,
);
if (file) {
driveItemVersion.file_metadata.thumbnails = file?.thumbnails;
await this.fileVersionRepository.save(driveItemVersion);
item.last_version_cache = driveItemVersion;
}
}
},
context,
);
await this.repository.save(item);
if (item.av_status === "skipped") {
// Notify the user that the document has been skipped
await this.notifyAVScanAlert(item, context);
}
} catch (error) {
this.logger.error(`Error scanning file ${driveItemVersion.file_metadata.external_id}`);
CrudException.throwMe(error, new CrudException("Failed to scan file", 500));
}
}
await globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
"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
* `DriveFile` entity to store a unique new value to expect an update later
@@ -1521,4 +1755,28 @@ export class DocumentsService {
sortField[sortFieldMapping[sort?.by] || "last_modified"] = sort?.order || "desc";
return sortField;
};
// Helper function to notify user about AV scan alert
notifyAVScanAlert = async (item: DriveFile, context: DriveExecutionContext) => {
await gr.services.documents.engine.notifyDocumentAVScanAlert({
context,
item,
notificationEmitter: context.user.id,
notificationReceiver: context.user.id,
});
};
// Helper function to update AV status and save the drive item
handleAVStatusUpdate = async (
item: DriveFile,
status: AVStatus,
context: DriveExecutionContext,
) => {
item.av_status = status;
await this.repository.save(item);
if (["malicious", "scan_failed"].includes(status)) {
await this.notifyAVScanAlert(item, context);
}
};
}
@@ -116,8 +116,15 @@ export type DriveTdriveTab = {
export enum DocumentEvents {
DOCUMENT_SAHRED = "document_shared",
DOCUMENT_VERSION_UPDATED = "document_version_updated",
DOCUMENT_AV_SCAN_ALERT = "document_av_scan_alert",
}
export const eventToTemplateMap: Record<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 = {
context: CompanyExecutionContext;
item: DriveFile;
@@ -82,6 +82,7 @@ export const getDefaultDriveItem = (
parent_id: item.parent_id || "root",
content_keywords: item.content_keywords || "",
scope: "personal",
av_status: "uploaded",
description: item.description || "",
access_info: item.access_info || {
entities: [
@@ -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
* @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),
});
fastify.route({
method: "POST",
url: `${serviceUrl}/:id/check_malware`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.containsMaliciousFiles.bind(documentsController),
});
fastify.route({
method: "POST",
url: `${serviceUrl}/:id/rescan`,
preValidation: [fastify.authenticateOptional],
handler: documentsController.rescan.bind(documentsController),
});
fastify.route({
method: "GET",
url: editingSessionBase, //TODO NONONO check authenticate*Optional*
@@ -84,6 +84,7 @@ const documentSchema = {
attachements: { type: "array" },
last_version_cache: fileVersionSchema,
scope: { type: "string" },
av_status: { type: "string" },
},
};
@@ -149,59 +149,8 @@ export class FileServiceImpl {
entity.upload_data.size = totalUploadedSize;
await this.repository.save(entity, context);
/** Send preview generation task */
if (entity.upload_data.size < this.max_preview_file_size) {
const document: PreviewMessageQueueRequest["document"] = {
id: JSON.stringify(_.pick(entity, "id", "company_id")),
provider: gr.platformServices.storage.getConnectorType(),
path: getFilePath(entity),
encryption_algo: this.algorithm,
encryption_key: entity.encryption_key,
chunks: entity.upload_data.chunks,
filename: entity.metadata.name,
mime: entity.metadata.mime,
};
const output = {
provider: gr.platformServices.storage.getConnectorType(),
path: `${getFilePath(entity)}/thumbnails/`,
encryption_algo: this.algorithm,
encryption_key: entity.encryption_key,
pages: 10,
};
entity.metadata.thumbnails_status = "waiting";
await this.repository.save(entity, context);
if (!options?.ignoreThumbnails) {
try {
await gr.platformServices.messageQueue.publish<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 */
/** Send preview generation task if av is not enabled */
if (!gr.services.av?.avEnabled) await this.generatePreview(entity, options, context);
}
}
@@ -268,6 +217,70 @@ export class FileServiceImpl {
};
}
generatePreview = async (
entity: File,
options: { waitForThumbnail?: boolean; ignoreThumbnails?: boolean },
context: CompanyExecutionContext,
) => {
if (entity.upload_data.size < this.max_preview_file_size) {
const { document, output } = this.previewPayload(entity);
entity.metadata.thumbnails_status = "waiting";
await this.repository.save(entity, context);
if (!options?.ignoreThumbnails) {
try {
await gr.platformServices.messageQueue.publish<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> {
if (!id || !context.company.id) {
return null;
@@ -460,6 +473,10 @@ export class FileServiceImpl {
return { success: false };
}
}
getEncryptionAlgorithm(): string {
return this.algorithm;
}
}
export const getFilePath = (entity: File): string => {
return `${gr.platformServices.storage.getHomeDir()}/files/${entity.company_id}/${
@@ -30,9 +30,11 @@ import { CompanyServiceImpl } from "./user/services/companies";
import { UserExternalLinksServiceImpl } from "./user/services/external_links";
import { UserServiceImpl } from "./user/services/users/service";
import { WorkspaceServiceImpl } from "./workspaces/services/workspace";
import { AVServiceImpl } from "./av/service";
import { PreviewEngine } from "./previews/services/files/engine";
import { I18nService } from "./i18n";
import { getConfigOrDefault } from "../utils/get-config";
type PlatformServices = {
auth: AuthServiceAPI;
@@ -67,6 +69,7 @@ type TdriveServices = {
documents: DocumentsService;
engine: DocumentsEngine;
};
av?: AVServiceImpl;
tags: TagsService;
i18n: I18nService;
};
@@ -132,6 +135,10 @@ class GlobalResolver {
i18n: await new I18nService().init(),
};
// AV service is optional
if (getConfigOrDefault("drive.featureAntivirus", false))
this.services.av = await new AVServiceImpl().init();
Object.keys(this.services).forEach((key: keyof TdriveServices) => {
assert(this.services[key], `Service ${key} was not initialized`);
if (this.services[key].constructor.name == "Object") {
@@ -67,6 +67,9 @@ export function formatCompany(
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: JSON.parse(
config.get("drive.featureManageAccess") || "true",
),
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]: JSON.parse(
config.get("drive.featureAntivirus") || "false",
),
},
{
...(res.plan?.features || {}),
@@ -95,6 +95,7 @@ export const companyObjectSchema = {
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" },
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]: { type: "boolean" },
guests: { type: "number" }, // to rename or delete
members: { type: "number" }, // to rename or delete
storage: { type: "number" }, // to rename or delete
@@ -87,6 +87,7 @@ export enum CompanyFeaturesEnum {
COMPANY_DISPLAY_EMAIL = "company:display_email",
COMPANY_USER_QUOTA = "company:user_quota",
COMPANY_MANAGE_ACCESS = "company:managed_access",
COMPANY_AV_ENABLED = "company:av_enabled",
}
export type CompanyFeaturesObject = {
@@ -100,6 +101,7 @@ export type CompanyFeaturesObject = {
[CompanyFeaturesEnum.COMPANY_DISPLAY_EMAIL]?: boolean;
[CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean;
[CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean;
[CompanyFeaturesEnum.COMPANY_AV_ENABLED]?: boolean;
};
export type CompanyLimitsObject = {
@@ -0,0 +1,5 @@
import config from "config";
export const getConfigOrDefault = (key: string, defaultValue: any) => {
return config.has(key) ? config.get(key) : defaultValue;
};