From c66123f4503cc8039925c2fa826e4f8e2562cd90 Mon Sep 17 00:00:00 2001 From: Anton SHEPILOV Date: Sun, 25 Aug 2024 22:26:29 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Added=20support=20of=20the=20session?= =?UTF-8?q?=20editing=20key=20for=20the=20OnlyOffice=20connector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/documents/services/index.ts | 39 +++++++------ .../documents/web/controllers/documents.ts | 39 ++++++++++++- .../node/src/services/documents/web/routes.ts | 2 +- .../backend/node/test/e2e/common/user-api.ts | 30 ++++++++++ .../test/e2e/common/user-authorization.ts | 52 ++++++++++++++++++ .../e2e/documents/editing-session.spec.ts | 25 ++++++++- .../src/controllers/onlyoffice.controller.ts | 23 +------- .../src/interfaces/drive.interface.ts | 5 +- .../src/services/drive.service.ts | 55 ++++++++++++++++--- 9 files changed, 217 insertions(+), 53 deletions(-) create mode 100644 tdrive/backend/node/test/e2e/common/user-authorization.ts diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index b8c4ae54..74aebab4 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -61,6 +61,8 @@ 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"; export class DocumentsService { version: "1"; @@ -1030,13 +1032,15 @@ export class DocumentsService { * End editing session either by providing a URL to a new file to create a version, * or not, to just cancel the session. * @param editing_session_key Editing key of the DriveFile - * @param newFileUrl Optional URL to a new version of the file to store + * @param file Multipart files from the incoming http request * @param options Optional upload information from the request + * @param context */ endEditing = async ( editing_session_key: string, - newFileUrl: string | null, - context: DriveExecutionContext, + file: MultipartFile, + options: UploadOptions, + context: CompanyExecutionContext, ) => { if (!context) { this.logger.error("invalid execution context"); @@ -1062,19 +1066,21 @@ export class DocumentsService { ); } - if (newFileUrl) { - // const response = await axios.request({ - // url: domain + req.url, - // method: req.method as any, - // headers: _.omit(req.headers, "host", "content-length") as { - // [key: string]: string; - // }, - // data: req.body as any, - // responseType: "stream", - // maxRedirects: 0, - // validateStatus: null, - // }); - //TODO: save file into new version + if (file) { + const fileEntity = await globalResolver.services.files.save(null, file, options, context); + + await globalResolver.services.documents.documents.createVersion( + driveFile.id, + { + drive_item_id: driveFile.id, + provider: "internal", + file_metadata: { + external_id: fileEntity.id, + source: "internal", + }, + }, + context, + ); } try { @@ -1092,7 +1098,6 @@ export class DocumentsService { result.currentValue, )}`, ); - return { ok: true }; } catch (error) { logger.error({ error: `${error}` }, "Failed to cancel editing Drive item"); CrudException.throwMe(error, new CrudException("Failed to cancel editing Drive item", 500)); 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 9cc4c37a..0084350e 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -392,6 +392,7 @@ export class DocumentsController { return await globalResolver.services.documents.documents.endEditing( editing_session_key, null, + null, context, ); } catch (error) { @@ -406,10 +407,44 @@ export class DocumentsController { endEditing = async ( request: FastifyRequest<{ Params: ItemRequestByEditingSessionKeyParams; - Body: { editorApplicationId: string }; + Querystring: Record; + Body: { + item: Partial; + version: Partial; + }; }>, ) => { - console.log(request); // make linter happy + const { editing_session_key } = request.params; + if (!editing_session_key) throw new CrudException("Editing session key must be set", 400); + + const context = getDriveExecutionContext(request); + + if (request.isMultipart()) { + const file = await request.file(); + const q: Record = request.query; + const options: UploadOptions = { + totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1, + totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0, + chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1, + filename: q.resumableFilename || q.filename || file?.filename || undefined, + type: q.resumableType || q.type || file?.mimetype || undefined, + waitForThumbnail: !!q.thumbnail_sync, + ignoreThumbnails: false, + }; + return await globalResolver.services.documents.documents.endEditing( + editing_session_key, + file, + options, + context, + ); + } else { + return await globalResolver.services.documents.documents.endEditing( + editing_session_key, + null, + null, + context, + ); + } }; downloadGetToken = async ( diff --git a/tdrive/backend/node/src/services/documents/web/routes.ts b/tdrive/backend/node/src/services/documents/web/routes.ts index be75de4d..48a4caeb 100644 --- a/tdrive/backend/node/src/services/documents/web/routes.ts +++ b/tdrive/backend/node/src/services/documents/web/routes.ts @@ -105,7 +105,7 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) method: "DELETE", url: `${serviceUrl}/editing_session/:editing_session_key`, preValidation: [fastify.authenticateOptional], - handler: documentsController.endEditing.bind(documentsController), + handler: documentsController.cancelEditing.bind(documentsController), }); fastify.route({ diff --git a/tdrive/backend/node/test/e2e/common/user-api.ts b/tdrive/backend/node/test/e2e/common/user-api.ts index c41c6fc9..c0186901 100644 --- a/tdrive/backend/node/test/e2e/common/user-api.ts +++ b/tdrive/backend/node/test/e2e/common/user-api.ts @@ -409,6 +409,36 @@ export default class UserApi { }); } + async endEditingDocument( + editingSessionKey: string + ): Promise { + const fullPath = `${__dirname}/assets/${UserApi.ALL_FILES[0]}`; + const readable= Readable.from(fs.createReadStream(fullPath)); + const form = formAutoContent({ file: readable }); + form.headers["authorization"] = `Bearer ${this.jwt}`; + + return await this.platform.app.inject({ + method: "POST", + url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/editing_session/${editingSessionKey}`, + headers: { + authorization: `Bearer ${this.jwt}` + }, + ...form, + }); + } + + async cancelEditingDocument( + editingSessionKey: string, + ): Promise { + return await this.platform.app.inject({ + method: "DELETE", + url: `${UserApi.DOC_URL}/companies/${this.platform.workspace.company_id}/item/editing_session/${editingSessionKey}`, + headers: { + authorization: `Bearer ${this.jwt}` + } + }); + } + async beginEditingDocumentExpectOk( driveFileId: string, editorApplicationId: string, diff --git a/tdrive/backend/node/test/e2e/common/user-authorization.ts b/tdrive/backend/node/test/e2e/common/user-authorization.ts new file mode 100644 index 00000000..8a36ac19 --- /dev/null +++ b/tdrive/backend/node/test/e2e/common/user-authorization.ts @@ -0,0 +1,52 @@ +// import { OidcJwtVerifier } from "../../../src/services/console/clients/remote-jwks-verifier"; +// +// export class UserAuthorization { +// /** +// * Just send the login requests without any validation and login response assertion +// */ +// public async login(session?: string) { +// if (session !== undefined) { +// this.session = session; +// } else { +// this.session = uuidv1(); +// } +// const payload = { +// claims: { +// sub: this.user.id, +// first_name: this.user.first_name, +// sid: this.session, +// }, +// }; +// const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyIdToken"); +// verifierMock.mockImplementation(() => { +// return Promise.resolve(payload); // Return the predefined payload +// }); +// return await this.api.post("/internal/services/console/v1/login", { +// oidc_id_token: "sample_oidc_token", +// }); +// } +// +// public async logout() { +// const payload = { +// claims: { +// iss: "tdrive_lemonldap", +// sub: this.user.id, +// sid: this.session, +// aud: "your-audience", +// iat: Math.floor(Date.now() / 1000), +// jti: "jwt-id", +// events: { +// "http://schemas.openid.net/event/backchannel-logout": {}, +// }, +// } +// }; +// const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyLogoutToken"); +// verifierMock.mockImplementation(() => { +// return Promise.resolve(payload); // Return the predefined payload +// }); +// +// return await this.api.post("/internal/services/console/v1/backchannel_logout", { +// logout_token: "logout_token_rsa256", +// }); +// } +// } \ No newline at end of file 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 8c5e6ce5..845ec9c9 100644 --- a/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts +++ b/tdrive/backend/node/test/e2e/documents/editing-session.spec.ts @@ -4,6 +4,7 @@ import { init, TestPlatform } from "../setup"; import UserApi from "../common/user-api"; import { DriveFile, TYPE as DriveFileType } from "../../../src/services/documents/entities/drive-file"; +import exp = require("node:constants"); describe("the Drive's documents' editing session kind-of-lock", () => { let platform: TestPlatform | null; @@ -110,8 +111,28 @@ describe("the Drive's documents' editing session kind-of-lock", () => { expect(temporaryDocument.id).toBe(foundDocumentResult.json().id); }); - it('can end an editing session on a document only once with the right key', async () => { + it('can cancel an editing session on a document only once with the right key', async () => { + //given const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); - + //when + const response = await currentUser.cancelEditingDocument(editingSessionKey); + //then + expect(response.statusCode).toBe(200); + const newSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + expect(newSessionKey).not.toEqual(editingSessionKey); }); + + it('can end editing with a new version of document', async () => { + //given + const editingSessionKey = await currentUser.beginEditingDocumentExpectOk(temporaryDocument.id, 'e2e_testing'); + //when + const response = await currentUser.endEditingDocument(editingSessionKey); + + //then + expect(response.statusCode).toBe(200); + const document = await currentUser.getDocumentOKCheck(temporaryDocument.id); + expect(document.versions.length).toEqual(2); + }); + + }); diff --git a/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts b/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts index 7bce33f6..fcd16c84 100644 --- a/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts +++ b/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts @@ -82,28 +82,9 @@ class OnlyOfficeController { break; case OnlyOffice.Callback.Status.READY_FOR_SAVING: - const driveFile = await driveService.getByEditingSessionKey({ - company_id, - editing_session_key, - }); - if (!driveFile) { - throw new Error('Error getting drive files '); - } - - const newVersionFile = await fileService.save({ - company_id, - file_id, - url, - create_new: true, - }); - - const version = await driveService.createVersion({ - company_id, - drive_file_id, - file_id: newVersionFile?.resource?.id, - }); - logger.info('New version created', version); + await driveService.endEditing(company_id, editing_session_key, url); + logger.info(`New version for session ${editing_session_key} created`); return respondToOO(); case OnlyOffice.Callback.Status.CLOSED_WITHOUT_CHANGES: diff --git a/tdrive/connectors/onlyoffice-connector/src/interfaces/drive.interface.ts b/tdrive/connectors/onlyoffice-connector/src/interfaces/drive.interface.ts index f17be95f..c7b94f3a 100644 --- a/tdrive/connectors/onlyoffice-connector/src/interfaces/drive.interface.ts +++ b/tdrive/connectors/onlyoffice-connector/src/interfaces/drive.interface.ts @@ -6,6 +6,7 @@ export type DriveFileType = { date_added: number; file_metadata: { external_id: string; + name?: string; }; }; }; @@ -20,6 +21,6 @@ export interface IDriveService { get: (params: DriveRequestParams) => Promise; createVersion: (params: { company_id: string; drive_file_id: string; file_id: string }) => Promise; beginEditingSession: (company_id: string, drive_file_id: string) => Promise; - endEditing: (company_id: string, editing_session_key: string) => Promise; - getByEditingSessionKey: (params: { company_id: string; editing_session_key: string; user_token?: string }) => Promise; + endEditing: (company_id: string, editing_session_key: string, file_source_url: string) => Promise; + getByEditingSessionKey: (params: { company_id: string; editing_session_key: string; user_token?: string }) => Promise; } diff --git a/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts index 53c018ca..24046353 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts @@ -1,6 +1,8 @@ import { DriveFileType, IDriveService } from '@/interfaces/drive.interface'; import apiService from './api.service'; import logger from '../lib/logger'; +import { Stream } from 'stream'; +import FormData from 'form-data'; /** * Client for Twake Drive's APIs dealing with `DriveItem`s, using {@see apiService} @@ -30,7 +32,7 @@ class DriveService implements IDriveService { }): Promise => { try { const { company_id, drive_file_id, file_id } = params; - const resource = await apiService.post<{}, DriveFileType['item']['last_version_cache']>({ + return await apiService.post<{}, DriveFileType['item']['last_version_cache']>({ url: `/internal/services/documents/v1/companies/${company_id}/item/${drive_file_id}/version`, payload: { drive_item_id: drive_file_id, @@ -41,8 +43,6 @@ class DriveService implements IDriveService { }, }, }); - - return resource; } catch (error) { logger.error('Failed to create version: ', error.stack); return Promise.reject(); @@ -68,10 +68,49 @@ class DriveService implements IDriveService { } } - public async endEditing(company_id: string, editing_session_key: string) { + public async cancelEditing(company_id: string, editing_session_key) { try { await apiService.delete<{}>({ - url: `/internal/services/documents/v1/companies/${company_id}/item/editing_session/${editing_session_key}`, + url: `/internal/services/documents/v1/companies/${company_id}/item/editing_session/${encodeURIComponent(editing_session_key)}`, + }); + } catch (error) { + logger.error('Failed to begin editing session: ', error.stack); + throw error; + //TODO make monitoring for such kind of errors + } + } + + public async endEditing(company_id: string, editing_session_key: string, url: string) { + try { + if (!url) { + throw Error('no url found'); + } + + const originalFile = await this.getByEditingSessionKey({ company_id, editing_session_key }); + + if (!originalFile) { + throw Error('original file not found'); + } + + const newFile = await apiService.get({ + url, + responseType: 'stream', + }); + + const form = new FormData(); + + const filename = encodeURIComponent(originalFile.last_version_cache.file_metadata.name); + + form.append('file', newFile, { + filename, + }); + + logger.info('Saving file version to Twake Drive: ', filename); + + await apiService.post({ + url: `/internal/services/documents/v1/companies/${company_id}/item/editing_session/${encodeURIComponent(editing_session_key)}`, + payload: form, + headers: form.getHeaders(), }); } catch (error) { logger.error('Failed to begin editing session: ', error.stack); @@ -89,11 +128,11 @@ class DriveService implements IDriveService { company_id: string; editing_session_key: string; user_token?: string; - }): Promise => { + }): Promise => { try { const { company_id, editing_session_key } = params; - return await apiService.get({ - url: `/internal/services/documents/v1/companies/${company_id}/item/editing_session/${editing_session_key}`, + return await apiService.get({ + url: `/internal/services/documents/v1/companies/${company_id}/item/editing_session/${encodeURIComponent(editing_session_key)}`, token: params.user_token, }); } catch (error) {