diff --git a/Documentation/docs/plugins.md b/Documentation/docs/plugins.md index ba83a027..53fbf43b 100644 --- a/Documentation/docs/plugins.md +++ b/Documentation/docs/plugins.md @@ -43,7 +43,7 @@ Links in top right drop down application grid can be configured in the `applicat Plugins are defined as entries under the `applications.plugins` array, with at least the following properties: -- `id` is mandatory and must be unique to each plugin +- `id` is mandatory and must be unique to each plugin. It must be rather short, and alphanumeric with `_`s only. - `internal_domain` is the internal domain of the plugin's server, it must be the same as the one in the docker-compose.yml file for ex. or resolvable and accessible to the Twake Drive backend server. - `external_prefix` is the external URL prefix of the plugin exposed by the backend and proxied to the `internal_domain`. - `api.private_key` is the shared secret used by the plugin's server to authentify to the backend diff --git a/tdrive/backend/node/src/services/documents/entities/drive-file.ts b/tdrive/backend/node/src/services/documents/entities/drive-file.ts index e3667a7d..d6f01ccf 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.ts @@ -1,4 +1,5 @@ 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"; @@ -93,8 +94,9 @@ export class DriveFile { /** * If this field is non-null, then an editing session is in progress (probably in OnlyOffice). - * Should be in the format `timestamp-appid-hexuuid` where `appid` and `timestamp` have no `-` - * characters. + * Use {@see EditingSessionKeyFormat} to generate and interpret it. + * Values should ensure that sorting lexicographically is chronological (assuming perfect clocks everywhere), + * and that the application and user that started the edit session are retrievable. */ @Type(() => String) @Column("editing_session_key", "string") @@ -120,6 +122,81 @@ export class DriveFile { scope: DriveScope; } +/** Reference implementation for generating then parsing the {@link DriveFile.editing_session_key} field */ +export const EditingSessionKeyFormat = { + // OnlyOffice key limits: 128 chars, [0-9a-zA-z=_-] + // 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, userId: string) { + if (!/^[0-9a-zA-Z_-]+$/m.test(applicationId)) + throw new Error( + `Invalid applicationId string (${JSON.stringify( + applicationId, + )}). Must be short and only alpha numeric`, + ); + const isoUTCDateNoSpecialCharsNoMS = new Date() + .toISOString() + .replace(/\..+$/, "") + .replace(/[ZT:-]/g, ""); + const newKey = [ + isoUTCDateNoSpecialCharsNoMS, + applicationId, + userId.replace(/-+/g, ""), + randomUUID().replace(/-+/g, ""), + ].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, appId, userId, _random] = parts; + const timestampMatch = timestampStr.match( + /^(?\d{4})(?\d\d)(?\d\d)(?\d\d)(?\d\d)(?\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 userIdMatch = userId.match( + /^([a-z0-f]{8})([a-z0-f]{4})([a-z0-f]{4})([a-z0-f]{4})([a-z0-f]{12})$/i, + ); + if (!userIdMatch) + throw new Error( + `Invalid editingSessionKey (${JSON.stringify( + editingSessionKey, + )}). UserID has wrong number of digits`, + ); + const [, userIdPart1, userIdPart2, userIdPart3, userIdPart4, userIdPart5] = userIdMatch; + return { + timestamp: new Date( + Date.parse(`${[year, month, day].join("-")}T${[hour, minute, second].join(":")}Z`), + ), + applicationId: appId, + userId: [userIdPart1, userIdPart2, userIdPart3, userIdPart4, userIdPart5].join("-"), + }; + }, +}; + export type AccessInformation = { public?: { token: string; diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index 74aebab4..476e9a2b 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -13,7 +13,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, TYPE } from "../entities/drive-file"; +import { 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"; @@ -60,7 +60,6 @@ import { import archiver from "archiver"; import internal from "stream"; import config from "config"; -import { randomUUID } from "crypto"; import { MultipartFile } from "@fastify/multipart"; import { UploadOptions } from "src/services/files/types"; @@ -970,33 +969,18 @@ export class DocumentsService { editorApplicationId: string, context: DriveExecutionContext, ) => { - const isoUTCDateNoSpecialCharsNoMS = new Date() - .toISOString() - .replace(/\..+$/, "") - .replace(/[ZT:-]/g, ""); - const newKey = [ - isoUTCDateNoSpecialCharsNoMS, - editorApplicationId, - randomUUID().replace(/-+/g, ""), - ].join("-"); - // OnlyOffice key limits: 128 chars, [0-9a-zA-z=_-] - // 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. - if (newKey.length > 128 || !/^[0-9a-zA-Z=_]+$/m.test(editorApplicationId)) - CrudException.throwMe( - new Error('Invalid "editorApplicationId" string. Must be short and only alpha numeric'), - new CrudException("Invalid editorApplicationId", 400), - ); - if (!context) { this.logger.error("invalid execution context"); return null; } + let newKey: string; + try { + newKey = EditingSessionKeyFormat.generate(editorApplicationId, context.user.id); + } catch (e) { + CrudException.throwMe(e, new CrudException("Error generating new editing_session_key", 500)); + } + const hasAccess = await checkAccess(id, null, "write", this.repository, context); if (!hasAccess) { logger.error("user does not have access drive item " + id);