✨ backend: Add callback to application to check editing key status (#525)
This commit is contained in:
@@ -6,6 +6,8 @@ import WebServerAPI from "../../core/platform/services/webserver/provider";
|
|||||||
import Application from "../applications/entities/application";
|
import Application from "../applications/entities/application";
|
||||||
import web from "./web/index";
|
import web from "./web/index";
|
||||||
import { logger } from "../../core/platform/framework/logger";
|
import { logger } from "../../core/platform/framework/logger";
|
||||||
|
import { EditingSessionKeyFormat } from "../documents/entities/drive-file";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
@Prefix("/api")
|
@Prefix("/api")
|
||||||
export default class ApplicationsApiService extends TdriveService<undefined> {
|
export default class ApplicationsApiService extends TdriveService<undefined> {
|
||||||
@@ -72,6 +74,72 @@ export default class ApplicationsApiService extends TdriveService<undefined> {
|
|||||||
return this;
|
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<Application[]>("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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
// TODO: remove
|
||||||
api(): undefined {
|
api(): undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
+48
@@ -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<void>): Promise<void> {
|
||||||
|
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<RequestQuery>, res: Response): Promise<void> {
|
||||||
|
//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<RequestQuery>, res: Response): Promise<void> {
|
||||||
|
await ignoreMissingKeyErrorButNoneElse(res, async () => {
|
||||||
|
await onlyofficeService.deleteForgotten(req.params.editing_session_key);
|
||||||
|
await res.send({ done: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,21 @@ export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, nex
|
|||||||
return res.status(401).send({ message: 'invalid token' });
|
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<string, string>;
|
||||||
|
// 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);
|
const user = await userService.getCurrentUser(token);
|
||||||
|
|
||||||
if (!user || !user.id) {
|
if (!user || !user.id) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import { Routes } from '@/interfaces/routes.interface';
|
|||||||
import authMiddleware from '@/middlewares/auth.middleware';
|
import authMiddleware from '@/middlewares/auth.middleware';
|
||||||
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
||||||
import { Router } from 'express';
|
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
|
* 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.
|
* which return a webpage that instantiates the client side JS Only Office component.
|
||||||
*/
|
*/
|
||||||
class IndexRoute implements Routes {
|
class IndexRoute implements Routes {
|
||||||
public path = '/';
|
public path = SERVER_PREFIX;
|
||||||
public router = Router();
|
public router = Router();
|
||||||
public indexController: IndexController;
|
public indexController: IndexController;
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ class IndexRoute implements Routes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initRoutes = () => {
|
private initRoutes = () => {
|
||||||
this.router.get(this.path, requirementsMiddleware, authMiddleware, this.indexController.index);
|
this.router.get('/', requirementsMiddleware, authMiddleware, this.indexController.index);
|
||||||
this.router.get(this.path + 'editor', requirementsMiddleware, authMiddleware, this.indexController.editor);
|
this.router.get('/editor', requirementsMiddleware, authMiddleware, this.indexController.editor);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import OnlyOfficeController from '@/controllers/onlyoffice.controller';
|
|||||||
import { Routes } from '@/interfaces/routes.interface';
|
import { Routes } from '@/interfaces/routes.interface';
|
||||||
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
import requirementsMiddleware from '@/middlewares/requirements.middleware';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { SERVER_PREFIX } from '@config';
|
||||||
|
|
||||||
class OnlyOfficeRoute implements Routes {
|
class OnlyOfficeRoute implements Routes {
|
||||||
public path = '/';
|
public path = SERVER_PREFIX;
|
||||||
public router = Router();
|
public router = Router();
|
||||||
public onlyOfficeController: OnlyOfficeController;
|
public onlyOfficeController: OnlyOfficeController;
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ class OnlyOfficeRoute implements Routes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initRoutes = () => {
|
private initRoutes = () => {
|
||||||
this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read);
|
this.router.get(`:mode/read`, requirementsMiddleware, this.onlyOfficeController.read);
|
||||||
this.router.post(`${this.path}:mode/callback`, requirementsMiddleware, this.onlyOfficeController.ooCallback);
|
this.router.post(`:mode/callback`, requirementsMiddleware, this.onlyOfficeController.ooCallback);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import App from '@/app';
|
import App from '@/app';
|
||||||
import IndexRoute from './routes/index.route';
|
import IndexRoute from './routes/index.route';
|
||||||
import OnlyOfficeRoute from './routes/onlyoffice.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();
|
app.listen();
|
||||||
|
|||||||
@@ -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
|
* Helpers to define the protocol of the OnlyOffice editor service command API
|
||||||
* @see https://api.onlyoffice.com/editors/command/
|
* @see https://api.onlyoffice.com/editors/command/
|
||||||
@@ -77,16 +86,6 @@ namespace CommandService {
|
|||||||
error: Exclude<ErrorCode, ErrorCode.SUCCESS>;
|
error: Exclude<ErrorCode, ErrorCode.SUCCESS>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<TSuccessResponse extends SuccessResponse> {
|
abstract class BaseRequest<TSuccessResponse extends SuccessResponse> {
|
||||||
constructor(public readonly c: string) {}
|
constructor(public readonly c: string) {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user