Files
workavia-drive/tdrive/backend/node/src/services/documents/entities/drive-file.ts
T
Montassar Ghanmy 00f819fb6d 🐞 Add antivirus inside Twake Drive (#725)
🐞 Add antivirus inside Twake Drive (#725)
2024-11-19 17:39:18 +01:00

259 lines
8.9 KiB
TypeScript

import { Type } from "class-transformer";
import { randomUUID } from "crypto";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { DriveFileAccessLevel, publicAccessLevel } from "../types";
import { FileVersion } from "./file-version";
import search from "./drive-file.search";
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.
*
* DriveFile s do not have a notion of owner, only a creator. The `creator`
* is used to locate the root files. The `parent_id` fields then form the
* hierarchy.
*
* The `is_in_trash` field must be checked when enumerating as DrifeFiles with
* this field set are roots inside the trash folder. Only the root DriveFiles
* that were moved to trash have this set, items below them, while also in the
* trash, do not have this field set.
*
* The `parent_id` can point to the `id` of another `DriveFile`, or one
* of the named entries:
* - `"user_$userid"`: Root of the personal "My Drive" (and Trash) folder of the user in the string.
* Usually this is assumed to be the creator, there is no official way
* of extracting the user id from the `parent_id`.
* - `"root"`: Root of the creator's company "Shared Drive" feature
* - The following virtual values never appear in the stored `parent_id` field but are used
* in queries and URLs etc:
* - `"trash"`: used to query items at the root of the creator with `is_in_trash == true`,
* 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: [
["company_id", "parent_id"],
["company_id", "is_in_trash"],
// Needs to be globally unique (no `company_id`) because it's the only key that OnlyOffice
// will return to us to identify the document being edited. Convenient for other plugins too.
["editing_session_key"],
],
primaryKey: [["company_id"], "id"],
type: TYPE,
search,
})
export class DriveFile {
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Type(() => String)
@Column("parent_id", "string")
parent_id: string;
@Type(() => Boolean)
@Column("is_in_trash", "boolean")
is_in_trash: boolean;
@Type(() => Boolean)
@Column("is_directory", "boolean")
is_directory: boolean;
@Type(() => String)
@Column("name", "string")
name: string;
@Type(() => String)
@Column("extension", "string")
extension: string;
@Type(() => String)
@Column("description", "string")
description: string;
@Column("tags", "encoded_json")
tags: string[];
@Type(() => Number)
@Column("added", "number")
added: number;
@Type(() => Number)
@Column("last_modified", "number")
last_modified: number;
@Column("access_info", "encoded_json")
access_info: AccessInformation;
/**
* If this field is non-null, then an editing session is in progress (probably in OnlyOffice).
* Use {@see EditingSessionKeyFormat} to generate and interpret it.
* Values should ensure that sorting lexicographically is chronological (assuming perfect clocks everywhere),
* and that the application, company and user that started the edit session are retrievable.
* It is not encrypted.
*/
@Type(() => String)
@Column("editing_session_key", "string")
editing_session_key: string;
@Type(() => String)
@Column("content_keywords", "string")
content_keywords: string;
@Type(() => String)
@Column("creator", "uuid")
creator: string;
@Type(() => Number)
@Column("size", "number")
size: number;
@Column("last_version_cache", "encoded_json")
last_version_cache: Partial<FileVersion>;
@Type(() => String)
@Column("scope", "string")
scope: DriveScope;
@Type(() => String)
@Column("av_status", "string")
av_status: AVStatus;
}
const OnlyOfficeSafeDocKeyBase64 = {
// base64 uses `+/`, but base64url uses `-_` instead. Both use `=` as padding,
// which conflicts with EditingSessionKeyFormat so using `.` instead.
fromBuffer(buffer: Buffer) {
return buffer.toString("base64url").replace(/=/g, ".");
},
toBuffer(base64: string) {
return Buffer.from(base64.replace(/\./g, "="), "base64url");
},
};
function checkFieldValue(field: string, value: string, required: boolean = true) {
if (!required && !value) return;
if (!/^[0-9a-zA-Z_-]+$/m.test(value))
throw new Error(
`Invalid ${field} value (${JSON.stringify(
value,
)}). Must be short and only alpha numeric or '_' and '-'`,
);
}
/**
* Reference implementation for generating then parsing the {@link DriveFile.editing_session_key} field.
*
* Fields should be explicit, `instanceId` is for the case when we have multiple
* clients
*/
export const EditingSessionKeyFormat = {
// OnlyOffice key limits: 128 chars, [0-9a-zA-Z.=_-]
// See https://api.onlyoffice.com/editors/config/document#key
// This is specific to it, but the constraint seems strict enough
// that any other system needing such a unique identifier would find
// this compatible. This value must be ensured to be the strictest
// common denominator to all plugin/interop systems. Plugins that
// require something even stricter have the option of maintaining
// a look up table to an acceptable value.
generate(
applicationId: string,
instanceId: string,
companyId: string,
userId: string,
overrideTimeStamp?: Date,
) {
checkFieldValue("applicationId", applicationId);
checkFieldValue("instanceId", instanceId, false);
const isoUTCDateNoSpecialCharsNoMS = (overrideTimeStamp ?? new Date())
.toISOString()
.replace(/\..+$/, "")
.replace(/[ZT:-]/g, "");
const userIdBuffer = UUIDTools.bufferFromUUIDString(userId) as unknown as Uint8Array;
const companyIdBuffer = UUIDTools.bufferFromUUIDString(companyId) as unknown as Uint8Array;
const entropyBuffer = UUIDTools.bufferFromUUIDString(randomUUID()) as unknown as Uint8Array;
const idsString = OnlyOfficeSafeDocKeyBase64.fromBuffer(
Buffer.concat([companyIdBuffer, userIdBuffer, entropyBuffer]),
);
const newKey = [isoUTCDateNoSpecialCharsNoMS, applicationId, instanceId, idsString].join("=");
if (newKey.length > 128 || !/^[0-9a-zA-Z=_-]+$/m.test(newKey))
throw new Error(
`Invalid generated editingSessionKey (${JSON.stringify(
newKey,
)}) string. Must be <128 chars, and only contain [0-9a-zA-z=_-]`,
);
return newKey;
},
parse(editingSessionKey: string) {
const parts = editingSessionKey.split("=");
const expectedParts = 4;
if (parts.length !== expectedParts)
throw new Error(
`Invalid editingSessionKey (${JSON.stringify(
editingSessionKey,
)}). Expected ${expectedParts} parts`,
);
const [timestampStr, applicationId, instanceId, idsOOBase64String] = parts;
const timestampMatch = timestampStr.match(
/^(?<year>\d{4})(?<month>\d\d)(?<day>\d\d)(?<hour>\d\d)(?<minute>\d\d)(?<second>\d\d)$/,
);
if (!timestampMatch)
throw new Error(
`Invalid editingSessionKey (${JSON.stringify(
editingSessionKey,
)}). Didn't start with valid timestamp`,
);
const { year, month, day, hour, minute, second } = timestampMatch.groups!;
const idsBuffer = OnlyOfficeSafeDocKeyBase64.toBuffer(idsOOBase64String);
const companyId = UUIDTools.formattedUUIDInBufferArray(idsBuffer, 0);
const userId = UUIDTools.formattedUUIDInBufferArray(idsBuffer, 1);
return {
timestamp: new Date(
Date.parse(`${[year, month, day].join("-")}T${[hour, minute, second].join(":")}Z`),
),
applicationId,
instanceId,
companyId,
userId,
};
},
};
export type AccessInformation = {
public?: {
token: string;
password: string;
expiration: number;
level: publicAccessLevel;
};
entities: AuthEntity[];
};
export type AuthEntity = {
type: "user" | "channel" | "company" | "folder";
id: string | "parent";
level: publicAccessLevel | DriveFileAccessLevel;
grantor: string;
};