From 483b1b0688319d1dfe32fbbfd566c15b987d851d Mon Sep 17 00:00:00 2001 From: Eric Doughty-Papassideris Date: Sun, 25 Aug 2024 22:26:30 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20backend:=20Add=20callback=20to=20ap?= =?UTF-8?q?plication=20to=20check=20editing=20key=20status=20=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/applications-api/index.ts | 68 +++++++++++++++++++ .../backend-callbacks.controller.ts | 48 +++++++++++++ .../src/middlewares/auth.middleware.ts | 15 ++++ .../src/routes/backend-callbacks.route.ts | 21 ++++++ .../src/routes/index.route.ts | 7 +- .../src/routes/onlyoffice.route.ts | 7 +- .../onlyoffice-connector/src/server.ts | 3 +- .../src/services/onlyoffice.service.ts | 19 +++--- 8 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 tdrive/connectors/onlyoffice-connector/src/controllers/backend-callbacks.controller.ts create mode 100644 tdrive/connectors/onlyoffice-connector/src/routes/backend-callbacks.route.ts diff --git a/tdrive/backend/node/src/services/applications-api/index.ts b/tdrive/backend/node/src/services/applications-api/index.ts index 411b9230..312566cb 100644 --- a/tdrive/backend/node/src/services/applications-api/index.ts +++ b/tdrive/backend/node/src/services/applications-api/index.ts @@ -6,6 +6,8 @@ import WebServerAPI from "../../core/platform/services/webserver/provider"; import Application from "../applications/entities/application"; import web from "./web/index"; import { logger } from "../../core/platform/framework/logger"; +import { EditingSessionKeyFormat } from "../documents/entities/drive-file"; +import jwt from "jsonwebtoken"; @Prefix("/api") export default class ApplicationsApiService extends TdriveService { @@ -72,6 +74,72 @@ export default class ApplicationsApiService extends TdriveService { return this; } + /** Send a request to the plugin by its application id + * @param url Full URL that doesn't start with a `/` + */ + private async requestFromApplication( + method: "GET" | "POST" | "DELETE", + url: string, + appId: string, + ) { + const apps = config.get("applications.plugins") || []; + const app = apps.find(app => app.id === appId); + if (!app) throw new Error(`Unknown application.id ${JSON.stringify(appId)}`); + if (!app.internal_domain) + throw new Error(`application.id ${JSON.stringify(appId)} missing an internal_domain`); + const signature = jwt.sign( + { + ts: new Date().getTime(), + type: "tdriveToApplication", + application_id: appId, + }, + app.api.private_key, + ); + const domain = app.internal_domain.replace(/(\/$|^\/)/gm, ""); + const finalURL = `${domain}/${url}${ + url.indexOf("?") > -1 ? "&" : "?" + }token=${encodeURIComponent(signature)}`; + return axios.request({ + url: finalURL, + method: method, + headers: { + Authorization: signature, + }, + maxRedirects: 0, + }); + } + + /** + * Check status of `editing_session_key` in the corresponding application. + * @param editingSessionKey {@see DriveFile.editing_session_key} to check + * @returns a URL string if there is a pending version to add, `null` + * if the key is unknown. + */ + async checkPendingEditingStatus(editingSessionKey: string): Promise { + const parsedKey = EditingSessionKeyFormat.parse(editingSessionKey); + const response = await this.requestFromApplication( + "POST", + "tdriveApi/1/session/" + encodeURIComponent(editingSessionKey) + "/check", + parsedKey.applicationId, + ); + return (response.data.url as string) || null; + } + + /** + * Remove any reference to the `editing_session_key` in the plugin + * @param editingSessionKey {@see DriveFile.editing_session_key} to delete + * @returns `true` if the key was deleted + */ + async deleteEditingKey(editingSessionKey: string): Promise { + const parsedKey = EditingSessionKeyFormat.parse(editingSessionKey); + const response = await this.requestFromApplication( + "DELETE", + "tdriveApi/1/session/" + encodeURIComponent(editingSessionKey), + parsedKey.applicationId, + ); + return !!response.data.done as boolean; + } + // TODO: remove api(): undefined { return undefined; diff --git a/tdrive/connectors/onlyoffice-connector/src/controllers/backend-callbacks.controller.ts b/tdrive/connectors/onlyoffice-connector/src/controllers/backend-callbacks.controller.ts new file mode 100644 index 00000000..45a8801a --- /dev/null +++ b/tdrive/connectors/onlyoffice-connector/src/controllers/backend-callbacks.controller.ts @@ -0,0 +1,48 @@ +import { NextFunction, Request, Response } from 'express'; +import logger from '@/lib/logger'; +import onlyofficeService, { CommandError, ErrorCode } from '@/services/onlyoffice.service'; + +interface RequestQuery { + editing_session_key: string; +} + +async function ignoreMissingKeyErrorButNoneElse(res: Response, call: () => Promise): Promise { + try { + await call(); + } catch (e) { + if (e instanceof CommandError && e.errorCode == ErrorCode.KEY_MISSING_OR_DOC_NOT_FOUND) { + return void (await res.send({ info: 'Unknown editing_session_key' })); + } + logger.error('Running OO command for TDrive backend', e); + return void (await res.sendStatus(500)); + } +} + +/** + * These routes are called by Twake Drive backend, for ex. before editing or retreiving a file, + * if it has an editing_session_key still, get the status of that and force a resolution. + */ +export default class TwakeDriveBackendCallbackController { + /** + * Get status of an `editing_session_key` from OO, and return a URL to get the latest version, + * or an object with no `url` property, in which case the key is not known as forgotten by OO and should + * be considered lost after an admin alert. + */ + public async checkSessionStatus(req: Request, res: Response): Promise { + //TODO: Find a way to check if the key is live (`info` uses the callback url) + await ignoreMissingKeyErrorButNoneElse(res, async () => { + await res.send({ url: await onlyofficeService.getForgotten(req.params.editing_session_key) }); + }); + } + + /** + * Force deletion of the provided `editing_session_key` in the OO document server. + * If the key was succesfully deleted, the `done` property in the response body will be true. + */ + public async deleteSessionKey(req: Request, res: Response): Promise { + await ignoreMissingKeyErrorButNoneElse(res, async () => { + await onlyofficeService.deleteForgotten(req.params.editing_session_key); + await res.send({ done: true }); + }); + } +} diff --git a/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts b/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts index 8703da88..5b727c41 100644 --- a/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts +++ b/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts @@ -34,6 +34,21 @@ export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, nex return res.status(401).send({ message: 'invalid token' }); } + const authHeaderToken = req.header('authorization'); + try { + if (authHeaderToken) { + const fromTwakeDriveToken = jwt.verify(authHeaderToken, CREDENTIALS_SECRET) as Record; + // The following constant comes from tdrive/backend/node/src/services/applications-api/index.ts + // This is in the case when Twake Drive backend makes requests from the connector, + // but not on the behalf of a specific user, eg. when checking stale only office keys + if (fromTwakeDriveToken['type'] != 'tdriveToApplication') throw new Error(`Bad type in token ${JSON.stringify(fromTwakeDriveToken)}`); + return next(); + } + } catch (e) { + logger.error(`Invalid token in Authorization header from Twake Drive backend`, e); + return res.status(401).json({ message: 'invalid token' }); + } + const user = await userService.getCurrentUser(token); if (!user || !user.id) { diff --git a/tdrive/connectors/onlyoffice-connector/src/routes/backend-callbacks.route.ts b/tdrive/connectors/onlyoffice-connector/src/routes/backend-callbacks.route.ts new file mode 100644 index 00000000..527eb02e --- /dev/null +++ b/tdrive/connectors/onlyoffice-connector/src/routes/backend-callbacks.route.ts @@ -0,0 +1,21 @@ +import TwakeDriveBackendCallbackController from '@/controllers/backend-callbacks.controller'; +import { Routes } from '@/interfaces/routes.interface'; +import authMiddleware from '@/middlewares/auth.middleware'; +import { Router } from 'express'; + +/** + * These routes are called by Twake Drive backend, for ex. before editing or retreiving a file, + * if it has an editing_session_key still, get the status of that and force a resolution. + */ +export default class TwakeDriveBackendCallbackRoutes implements Routes { + public readonly router = Router(); + public readonly path = '/tdriveApi/1'; + private readonly controller: TwakeDriveBackendCallbackController; + + constructor() { + this.controller = new TwakeDriveBackendCallbackController(); + // Why post ? to garantee it is never cached and always ran + this.router.post('/session/:editing_session_key/check', authMiddleware, this.controller.checkSessionStatus); + this.router.delete('/session/:editing_session_key', authMiddleware, this.controller.deleteSessionKey); + } +} diff --git a/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts b/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts index 20fb8611..3e99c719 100644 --- a/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts +++ b/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts @@ -3,13 +3,14 @@ import { Routes } from '@/interfaces/routes.interface'; import authMiddleware from '@/middlewares/auth.middleware'; import requirementsMiddleware from '@/middlewares/requirements.middleware'; import { Router } from 'express'; +import { SERVER_PREFIX } from '@config'; /** * When the user previews or edits a file in Twake Drive, their browser is sent to these routes * which return a webpage that instantiates the client side JS Only Office component. */ class IndexRoute implements Routes { - public path = '/'; + public path = SERVER_PREFIX; public router = Router(); public indexController: IndexController; @@ -19,8 +20,8 @@ class IndexRoute implements Routes { } private initRoutes = () => { - this.router.get(this.path, requirementsMiddleware, authMiddleware, this.indexController.index); - this.router.get(this.path + 'editor', requirementsMiddleware, authMiddleware, this.indexController.editor); + this.router.get('/', requirementsMiddleware, authMiddleware, this.indexController.index); + this.router.get('/editor', requirementsMiddleware, authMiddleware, this.indexController.editor); }; } diff --git a/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts b/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts index 43aa3683..504c2c7e 100644 --- a/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts +++ b/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts @@ -2,9 +2,10 @@ import OnlyOfficeController from '@/controllers/onlyoffice.controller'; import { Routes } from '@/interfaces/routes.interface'; import requirementsMiddleware from '@/middlewares/requirements.middleware'; import { Router } from 'express'; +import { SERVER_PREFIX } from '@config'; class OnlyOfficeRoute implements Routes { - public path = '/'; + public path = SERVER_PREFIX; public router = Router(); public onlyOfficeController: OnlyOfficeController; @@ -14,8 +15,8 @@ class OnlyOfficeRoute implements Routes { } private initRoutes = () => { - this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read); - this.router.post(`${this.path}:mode/callback`, requirementsMiddleware, this.onlyOfficeController.ooCallback); + this.router.get(`:mode/read`, requirementsMiddleware, this.onlyOfficeController.read); + this.router.post(`:mode/callback`, requirementsMiddleware, this.onlyOfficeController.ooCallback); }; } diff --git a/tdrive/connectors/onlyoffice-connector/src/server.ts b/tdrive/connectors/onlyoffice-connector/src/server.ts index 139be10f..0c86358e 100644 --- a/tdrive/connectors/onlyoffice-connector/src/server.ts +++ b/tdrive/connectors/onlyoffice-connector/src/server.ts @@ -1,7 +1,8 @@ import App from '@/app'; import IndexRoute from './routes/index.route'; import OnlyOfficeRoute from './routes/onlyoffice.route'; +import TwakeDriveBackendCallbacksRoutes from './routes/backend-callbacks.route'; -const app = new App([new IndexRoute(), new OnlyOfficeRoute()]); +const app = new App([new IndexRoute(), new OnlyOfficeRoute(), new TwakeDriveBackendCallbacksRoutes()]); app.listen(); diff --git a/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts index d0f8e07d..887b6363 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts @@ -62,6 +62,15 @@ export namespace Callback { } } +/** For error responses from the {@see CommandService} */ +export class CommandError extends Error { + constructor(public readonly errorCode: ErrorCode, req: any, res: any) { + super( + `OnlyOffice command service error ${ErrorCodeFromValue(errorCode)} (${errorCode}): Requested ${JSON.stringify(req)} got ${JSON.stringify(res)}`, + ); + } +} + /** * Helpers to define the protocol of the OnlyOffice editor service command API * @see https://api.onlyoffice.com/editors/command/ @@ -77,16 +86,6 @@ namespace CommandService { error: Exclude; } - export class CommandError extends Error { - constructor(errorCode: ErrorCode, req: any, res: any) { - super( - `OnlyOffice command service error ${ErrorCodeFromValue(errorCode)} (${errorCode}): Requested ${JSON.stringify(req)} got ${JSON.stringify( - res, - )}`, - ); - } - } - abstract class BaseRequest { constructor(public readonly c: string) {}