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 b856d5c8..e3667a7d 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.ts @@ -93,7 +93,7 @@ export class DriveFile { /** * If this field is non-null, then an editing session is in progress (probably in OnlyOffice). - * Should be in the format `appid-timestamp-hexuuid` where `appid` and `timestamp` have no `-` + * Should be in the format `timestamp-appid-hexuuid` where `appid` and `timestamp` have no `-` * characters. */ @Type(() => String) diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index 53b4b054..d24e0f92 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -58,6 +58,9 @@ 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 "../../files/types"; export class DocumentsService { version: "1"; @@ -264,6 +267,45 @@ export class DocumentsService { }; }; + /** + * Fetches a drive element by its `editing_session_key` + * + * @param {string} editing_session_key - the editing_session_key of the DriveFile to fetch + * @param {DriveExecutionContext} context + * @returns {Promise} + */ + getByEditingSessionKey = async ( + editing_session_key: string, + context: DriveExecutionContext & { public_token?: string }, + ): Promise => { + if (!editing_session_key) { + this.logger.error("Invalid editing_session_key: " + JSON.stringify(editing_session_key)); + throw new CrudException("Invalid editing_session_key", 400); + } + if (!context) { + this.logger.error("invalid context"); + return null; + } + const entity = await this.repository.findOne({ editing_session_key }, {}, context); + if (!entity) { + this.logger.error("Drive item not found"); + throw new CrudException("Item not found", 404); + } + //Check access to entity + try { + //TODO: may need to check for read only if we permit readonly editors to join + const hasAccess = await checkAccess(entity.id, entity, "write", this.repository, context); + if (!hasAccess) { + this.logger.error("user does not have access drive item " + entity.id); + throw Error("user does not have access to this item"); + } + } catch (error) { + this.logger.error({ error: `${error}` }, "Failed to grant access to the drive item"); + throw new CrudException("User does not have access to this item or its children", 401); + } + return entity; + }; + getAccess = async ( id: string, userId: string, @@ -879,6 +921,78 @@ export class DocumentsService { } }; + /** + * 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 + * with only that key provided. + * @param id DriveFile ID of the document to begin editing + * @param editorApplicationId Editor/Application/Plugin specific identifier + * @param context + * @returns An object in the format `{}` with the unique identifier for the + * editing session + */ + beginEditing = async ( + id: string, + 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; + } + + const hasAccess = await checkAccess(id, null, "write", this.repository, context); + if (!hasAccess) { + logger.error("user does not have access drive item " + id); + CrudException.throwMe( + new Error("user does not have access to the drive item"), + new CrudException("user does not have access drive item", 401), + ); + } + + try { + const driveFile = await this.repository.findOne( + { + id, + company_id: context.company.id, + }, + {}, + context, + ); + const result = await this.repository.atomicCompareAndSet( + driveFile, + "editing_session_key", + null, + newKey, + ); + return { editingSessionKey: result.currentValue }; + } catch (error) { + logger.error({ error: `${error}` }, "Failed to begin editing Drive item"); + CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500)); + } + }; downloadGetToken = async ( ids: string[], versionId: string | null, diff --git a/tdrive/backend/node/src/services/documents/types.ts b/tdrive/backend/node/src/services/documents/types.ts index 733c83c5..a96136f3 100644 --- a/tdrive/backend/node/src/services/documents/types.ts +++ b/tdrive/backend/node/src/services/documents/types.ts @@ -17,6 +17,10 @@ export type ItemRequestParams = RequestParams & { id: string; }; +export type ItemRequestByEditingSessionKeyParams = RequestParams & { + editing_session_key: string; +}; + export type DriveItemDetails = { path: DriveFile[]; item?: DriveFile; diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts index 050e2c55..72bbc572 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -15,6 +15,7 @@ import { DriveItemDetails, DriveTdriveTab, ItemRequestParams, + ItemRequestByEditingSessionKeyParams, RequestParams, SearchDocumentsBody, SearchDocumentsOptions, @@ -165,6 +166,26 @@ export class DocumentsController { }; }; + /** + * Fetches a DriveFile item by its `editing_session_key`. + * + * @param {FastifyRequest} request + * @returns {Promise} + */ + getByEditingSessionKey = async ( + request: FastifyRequest<{ + Params: ItemRequestByEditingSessionKeyParams; + Querystring: PaginationQueryParameters & { public_token?: string }; + }>, + ): Promise => { + const context = getDriveExecutionContext(request); + const { editing_session_key } = request.params; + return await globalResolver.services.documents.documents.getByEditingSessionKey( + editing_session_key, + context, + ); + }; + /** * Browse file, special endpoint for TDrive application widget. * Returns the current folder with the filtered content @@ -310,6 +331,35 @@ export class DocumentsController { } }; + /** + * 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 + */ + beginEditing = async ( + request: FastifyRequest<{ + Params: ItemRequestParams; + Body: { editorApplicationId: string }; + }>, + ) => { + try { + const context = getDriveExecutionContext(request); + const { id } = request.params; + + if (!id) throw new CrudException("Missing id", 400); + if (!request.body.editorApplicationId) + throw new CrudException("Missing editorApplicationId", 400); + + return await globalResolver.services.documents.documents.beginEditing( + id, + request.body.editorApplicationId, + context, + ); + } catch (error) { + logger.error({ error: `${error}` }, "Failed to begin editing Drive item"); + CrudException.throwMe(error, new CrudException("Failed to begin editing Drive item", 500)); + } + }; + downloadGetToken = async ( request: FastifyRequest<{ Params: ItemRequestParams; diff --git a/tdrive/backend/node/src/services/documents/web/routes.ts b/tdrive/backend/node/src/services/documents/web/routes.ts index 70af3cbd..ed1e3e5d 100644 --- a/tdrive/backend/node/src/services/documents/web/routes.ts +++ b/tdrive/backend/node/src/services/documents/web/routes.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyPluginCallback } from "fastify"; import { DocumentsController } from "./controllers"; -import { createDocumentSchema, createVersionSchema } from "./schemas"; +import { createDocumentSchema, createVersionSchema, beginEditingSchema } from "./schemas"; // import profilerPlugin from "../../../utils/profiler"; const baseUrl = "/companies/:company_id"; @@ -79,6 +79,21 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) handler: documentsController.createVersion.bind(documentsController), }); + fastify.route({ + method: "POST", + url: `${serviceUrl}/:id/editing_session`, + preValidation: [fastify.authenticateOptional], + schema: beginEditingSchema, + handler: documentsController.beginEditing.bind(documentsController), + }); + + fastify.route({ + method: "GET", + url: `${serviceUrl}/editing_session/:editing_session_key`, + preValidation: [fastify.authenticateOptional], + handler: documentsController.getByEditingSessionKey.bind(documentsController), + }); + fastify.route({ method: "GET", url: `${serviceUrl}/download/token`, diff --git a/tdrive/backend/node/src/services/documents/web/schemas.ts b/tdrive/backend/node/src/services/documents/web/schemas.ts index 102384e9..81a095d2 100644 --- a/tdrive/backend/node/src/services/documents/web/schemas.ts +++ b/tdrive/backend/node/src/services/documents/web/schemas.ts @@ -109,3 +109,22 @@ export const createVersionSchema = { "2xx": fileVersionSchema, }, }; + +export const beginEditingSchema = { + body: { + type: "object", + properties: { + editorApplicationId: { type: "string" }, + }, + required: ["editorApplicationId"], + }, + response: { + "2xx": { + type: "object", + properties: { + editingSessionKey: { type: "string" }, + }, + required: ["editingSessionKey"], + }, + }, +}; diff --git a/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts b/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts index 8105cd6c..6106028c 100644 --- a/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts +++ b/tdrive/backend/node/test/e2e/common/entities/mock_entities.ts @@ -28,6 +28,7 @@ export class DriveFileMockClass { tags: string[]; last_modified: number; access_info: MockAccessInformation; + editing_session_key: string; creator: string; is_directory: boolean; scope: "personal" | "shared"; diff --git a/tdrive/backend/node/test/e2e/common/user-api.ts b/tdrive/backend/node/test/e2e/common/user-api.ts index f369ba91..d4830d24 100644 --- a/tdrive/backend/node/test/e2e/common/user-api.ts +++ b/tdrive/backend/node/test/e2e/common/user-api.ts @@ -396,6 +396,29 @@ export default class UserApi { }); }; + async beginEditingDocument( + driveFileId: string, + editorApplicationId: string, + ): Promise { + return await this.api.post( + `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/${driveFileId}/editing_session`, + { editorApplicationId }, + { + authorization: `Bearer ${this.jwt}` + }); + } + + async beginEditingDocumentExpectOk( + driveFileId: string, + editorApplicationId: string, + ): Promise { + const result = await this.beginEditingDocument(driveFileId, editorApplicationId); + expect(result.statusCode).toBe(200); + const {editingSessionKey} = result.json(); + expect(editingSessionKey).toBeTruthy(); + return editingSessionKey; + } + async searchDocument( payload: Record ) { @@ -459,6 +482,16 @@ export default class UserApi { return doc; }; + async getDocumentByEditingKey(editing_session_key: string) { + return await this.platform.app.inject({ + method: "GET", + url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/editing_session/${encodeURIComponent(editing_session_key)}`, + headers: { + authorization: `Bearer ${this.jwt}` + } + }); + }; + async sharedWithMeDocuments( payload: Record ) { diff --git a/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts b/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts index 3521ece7..8c5e6ce5 100644 --- a/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts +++ b/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts @@ -75,4 +75,43 @@ describe("the Drive's documents' editing session kind-of-lock", () => { expect(driveFileRepository?.atomicCompareAndSet(temporaryDocument, "editing_session_key", "124", null)). rejects.toThrow("no row matched PK"); }); + + it('rejects invalid editorApplicationId', async () => { + let result = await currentUser.beginEditingDocument(temporaryDocument.id, ''); + expect(result.statusCode).toBe(400); + result = await currentUser.beginEditingDocument(temporaryDocument.id, 'e2e-testing'); + expect(result.statusCode).toBe(400); + result = await currentUser.beginEditingDocument(temporaryDocument.id, 'e2e_testing_but_this_one_is_likely_too_long_the_max_is_128_with_added_guid_and_ts_added_after_and_counting_towards_128'); + expect(result.statusCode).toBe(400); + }); + + it('can begin an editing session on a document only once', async () => { + const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + const secondKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + expect(editingSessionKey).toBe(secondKey); + const document = await currentUser.getDocumentOKCheck(temporaryDocument.id) + expect(document.item.editing_session_key).toBe(editingSessionKey); + }); + + it('cannot begin an editing session on a document without write permissions', async () => { + const secondUser = await UserApi.getInstance(platform!, true); + const editingResult = await secondUser.beginEditingDocument(temporaryDocument.id, 'e2e_testing'); + expect(editingResult.statusCode).toBe(401); + }); + + it('can retreive the document from the editing session key', async () => { + const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + let foundDocumentResult = await currentUser.getDocumentByEditingKey(editingSessionKey + "-made-wrong"); + expect(foundDocumentResult.statusCode).toBe(404); + foundDocumentResult = await currentUser.getDocumentByEditingKey(""); + expect(foundDocumentResult.statusCode).toBe(400); + foundDocumentResult = await currentUser.getDocumentByEditingKey(editingSessionKey); + expect(foundDocumentResult.statusCode).toBe(200); + expect(temporaryDocument.id).toBe(foundDocumentResult.json().id); + }); + + it('can end an editing session on a document only once with the right key', async () => { + const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + + }); });