diff --git a/tdrive/connectors/onlyoffice-connector/.eslintrc b/tdrive/connectors/onlyoffice-connector/.eslintrc index 7d57747f5..19c1a8206 100644 --- a/tdrive/connectors/onlyoffice-connector/.eslintrc +++ b/tdrive/connectors/onlyoffice-connector/.eslintrc @@ -28,6 +28,7 @@ "@typescript-eslint/explicit-module-boundary-types": 0, "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-var-requires": "off" } } diff --git a/tdrive/connectors/onlyoffice-connector/src/app.ts b/tdrive/connectors/onlyoffice-connector/src/app.ts index be49575a7..65258ba9a 100644 --- a/tdrive/connectors/onlyoffice-connector/src/app.ts +++ b/tdrive/connectors/onlyoffice-connector/src/app.ts @@ -6,8 +6,10 @@ import { renderFile } from 'eta'; import path from 'path'; import errorMiddleware from './middlewares/error.middleware'; import { SERVER_PORT, SERVER_PREFIX } from '@config'; -import loggerService from './services/logger.service'; +import logger from './lib/logger'; import cookieParser from 'cookie-parser'; +import apiService from './services/api.service'; +import onlyofficeService from './services/onlyoffice.service'; class App { public app: express.Application; @@ -25,8 +27,8 @@ class App { } public listen = () => { - this.app.listen(SERVER_PORT, () => { - loggerService.info(`🚀 App listening on port ${SERVER_PORT}`); + this.app.listen(parseInt(SERVER_PORT, 10), '0.0.0.0', () => { + logger.info(`🚀 App listening on port ${SERVER_PORT}`); }); }; @@ -34,7 +36,7 @@ class App { private initRoutes = (routes: Routes[]) => { this.app.use((req, res, next) => { - console.log('Requested: ', req.method, req.originalUrl); + logger.info(`Received request: ${req.method} ${req.originalUrl} from ${req.header('user-agent')} (${req.ip})`); next(); }); @@ -42,6 +44,13 @@ class App { this.app.use(SERVER_PREFIX, route.router); }); + this.app.get('/health', (_req, res) => { + Promise.all([onlyofficeService.getLatestVersion(), apiService.hasToken()]).then( + ([version, twakeDriveToken]) => res.status(version && twakeDriveToken ? 200 : 500).send({ version, twakeDriveToken }), + err => res.status(500).send(err), + ); + }); + this.app.use( SERVER_PREFIX.replace(/\/$/, '') + '/assets', (req, res, next) => { diff --git a/tdrive/connectors/onlyoffice-connector/src/controllers/index.controller.ts b/tdrive/connectors/onlyoffice-connector/src/controllers/index.controller.ts index 08b9f0e56..61e6c8734 100644 --- a/tdrive/connectors/onlyoffice-connector/src/controllers/index.controller.ts +++ b/tdrive/connectors/onlyoffice-connector/src/controllers/index.controller.ts @@ -6,7 +6,7 @@ import driveService from '@/services/drive.service'; import { DriveFileType } from '@/interfaces/drive.interface'; import fileService from '@/services/file.service'; import { OfficeToken } from '@/interfaces/routes.interface'; -import loggerService from '@/services/logger.service'; +import logger from '@/lib/logger'; import * as Utils from '@/utils'; interface RequestQuery { @@ -96,7 +96,7 @@ class IndexController { company_id, preview, office_token: officeToken, - }) + }), ); } catch (error) { next(error); @@ -134,7 +134,7 @@ class IndexController { token: inPageToken, }); } catch (error) { - loggerService.error(error); + logger.error(error); next(error); } }; diff --git a/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts b/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts index 494830816..5c368082a 100644 --- a/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts +++ b/tdrive/connectors/onlyoffice-connector/src/controllers/onlyoffice.controller.ts @@ -1,10 +1,9 @@ import { CREDENTIALS_SECRET } from '@/config'; import { OfficeToken } from '@/interfaces/routes.interface'; -import apiService from '@/services/api.service'; import driveService from '@/services/drive.service'; import fileService from '@/services/file.service'; -import loggerService from '@/services/logger.service'; -import onlyofficeService, * as OnlyOffice from '@/services/onlyoffice.service'; +import logger from '@/lib/logger'; +import * as OnlyOffice from '@/services/onlyoffice.service'; import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; @@ -14,14 +13,6 @@ interface RequestQuery { token: string; } -interface SaveRequestBody { - filetype: string; - key: string; - status: number; - url: string; - users: string[]; -} - /** These expose a OnlyOffice document storage service methods, called by the OnlyOffice document editing service * to load and save files */ @@ -66,78 +57,72 @@ class OnlyOfficeController { }; /** - * Receive a file from OnlyOffice document editing service and save it into Twake Drive backend + * Receive a file from OnlyOffice document editing service and save it into Twake Drive backend. + * + * This is the endpoint of the callback url provided to the editor in the browser. * * Parameters are standard Express middleware. * @see https://api.onlyoffice.com/editors/save * @see https://api.onlyoffice.com/editors/callback */ - public save = async (req: Request<{}, {}, SaveRequestBody, RequestQuery>, res: Response, next: NextFunction): Promise => { + public ooCallback = async ( + req: Request<{}, {}, OnlyOffice.Callback.Parameters, RequestQuery>, + res: Response, + next: NextFunction, + ): Promise => { + const respondToOO = (error = 0) => void res.send({ error }); try { const { url, key } = req.body; const { token } = req.query; - loggerService.info('Save request', { key, url, token }); + logger.info('Save request', { key, url, token }); const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken; - const { preview, company_id, file_id, user_id, drive_file_id, in_page_token } = officeTokenPayload; + const { preview, company_id, file_id, /* user_id, */ drive_file_id, in_page_token } = officeTokenPayload; // check token is an in_page_token and allow save if (!in_page_token) throw new Error('Invalid token, must be a in_page_token'); if (preview) throw new Error('Invalid token, must not be a preview token for save operation'); - if (url) { - loggerService.info('URL present, saving file'); - // If token indicate a drive_file_id then check if we want to create a new version or not - if (drive_file_id) { - loggerService.info('Drive file id present, checking if we need to create a new version'); - //Get the drive file - const driveFile = await driveService.get({ + switch (req.body.status) { + case OnlyOffice.Callback.Status.BEING_EDITED: + case OnlyOffice.Callback.Status.BEING_EDITED_BUT_IS_SAVED: + // No-op + break; + + case OnlyOffice.Callback.Status.READY_FOR_SAVING: + 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); + return respondToOO(); - // const createNewVersion = !!driveFile; //Always create a new version because needed by OnlyOffice // driveFile.item.last_version_cache.date_added < Date.now() - 1000 * 60 * 60 * 3; - const createNewVersion = true; - if (createNewVersion) { - const newVersionFile = await fileService.save({ - company_id, - file_id, - url, - create_new: true, - }); - - await driveService.createVersion({ - company_id, - drive_file_id, - file_id: newVersionFile?.resource?.id, - }); - loggerService.info('New version created'); - - res.send({ - error: 0, - }); - - return; - } - } - loggerService.info('Saving file'); + case OnlyOffice.Callback.Status.CLOSED_WITHOUT_CHANGES: + // Save end of transaction + break; - await fileService.save({ - company_id, - file_id, - url, - user_id: user_id, - }); - } else { - loggerService.error('URL not present, force saving file'); - await onlyofficeService.forceSave(key).catch(error => { - loggerService.warn("Expected error while force saving without changes (ignored):", error); - }); - } + case OnlyOffice.Callback.Status.ERROR_SAVING: + // Save end of transaction + logger.info(`Error saving file ${req.body.url} (key: ${req.body.key})`); + break; - res.send({ - error: 0, - }); + case OnlyOffice.Callback.Status.ERROR_FORCE_SAVING: + // TODO: notify user ? + logger.info(`Error force saving (reason: ${req.body.forcesavetype}) file ${req.body.url} (key: ${req.body.key})`); + return void res.send({ error: 0 }); + + default: + throw new Error(`Unexpected OO Callback status field: ${req.body.status}`); + } + return respondToOO(); } catch (error) { next(error); } diff --git a/tdrive/connectors/onlyoffice-connector/src/services/logger.service.ts b/tdrive/connectors/onlyoffice-connector/src/lib/logger.ts similarity index 100% rename from tdrive/connectors/onlyoffice-connector/src/services/logger.service.ts rename to tdrive/connectors/onlyoffice-connector/src/lib/logger.ts diff --git a/tdrive/connectors/onlyoffice-connector/src/lib/polled-thingie-value.ts b/tdrive/connectors/onlyoffice-connector/src/lib/polled-thingie-value.ts new file mode 100644 index 000000000..d4c2d8a13 --- /dev/null +++ b/tdrive/connectors/onlyoffice-connector/src/lib/polled-thingie-value.ts @@ -0,0 +1,98 @@ +import logger from './logger'; + +const now = () => new Date().getTime(); +type NotUndefined = T extends undefined ? never : T; + +/** + * Hold a value that is periodically obtained from a fallible process, and + * track update times and errors. + */ +export class PolledThingieValue { + private lastOkTimeMs: number | undefined; + private lastKoTimeMs: number | undefined; + protected lastValue: ValueType | undefined; + protected lastKoError: any; + protected pendingTry: Promise | undefined; + + constructor( + private readonly logPrefix: string, + private readonly getTheThingieValue: () => Promise>, + private readonly intervalMs: number, + ) { + this.run(); + setInterval(() => this.run(), this.intervalMs); + } + + protected setResult(value: undefined, error?: NotUndefined, ts?: number); + protected setResult(value: NotUndefined, error?: undefined, ts?: number); + protected setResult(value: ValueType | undefined, error?: any, ts = now()) { + if (value && error) throw new Error(`Unexpected value (${JSON.stringify(value)}) and error (${JSON.stringify(error)})`); + if (error) this.lastKoTimeMs = ts; + else this.lastOkTimeMs = ts; + this.lastValue = value; + this.lastKoError = error; + if (error) logger.error(this.logPrefix + ' error:', error.stack); + } + + private async run() { + if (this.pendingTry) return this.pendingTry; + return (this.pendingTry = new Promise((resolve, reject) => { + this.getTheThingieValue().then( + value => { + this.setResult(value === undefined ? null : value); + this.pendingTry = undefined; + resolve(value); + }, + error => { + this.setResult(undefined, error.stack); + this.pendingTry = undefined; + reject(error); + }, + ); + })); + } + + public lastFailed() { + return !!this.lastKoTimeMs; + } + + public hasValue() { + return !!this.lastOkTimeMs; + } + + /** Get the latest value and age in seconds if it was successful, or `undefined` */ + public latest(): { value: ValueType; ageS: number } | undefined { + if (!this.hasValue()) return undefined; + return { + value: this.lastValue, + ageS: Math.floor((now() - this.lastOkTimeMs) / 1000), + }; + } + + /** Get the latest value if it was successful, or `undefined` */ + public latestValue(): ValueType | undefined { + if (!this.hasValue()) return undefined; + return this.lastValue; + } + + /** + * Get a promise to the latest value. If there isn't one, try to get one first. + * New requests when one is already pending will not cause a separate run, + * but get the previous promise back. + **/ + public async latestValueWithTry(): Promise { + if (this.hasValue()) return this.lastValue; + if (this.pendingTry) return this.pendingTry; + return this.run(); + } + + /** + * Use {@see latestValueWithTry} and if the result is still `undefined`, reject + * the promise with the provided `errorMessage`. + **/ + public async requireLatestValueWithTry(errorMessage: string): Promise { + const result = await this.latestValueWithTry(); + if (result === undefined) throw new Error(errorMessage); + return result; + } +} diff --git a/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts b/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts index 73eb62222..8703da88f 100644 --- a/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts +++ b/tdrive/connectors/onlyoffice-connector/src/middlewares/auth.middleware.ts @@ -1,5 +1,5 @@ import { CREDENTIALS_SECRET } from '@/config'; -import loggerService from '@/services/logger.service'; +import logger from '@/lib/logger'; import userService from '@/services/user.service'; import { NextFunction, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; @@ -25,7 +25,7 @@ export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, nex return; } } catch (error) { - loggerService.error('invalid token', error); + logger.error('invalid token', error.stack); res.clearCookie('token'); } } diff --git a/tdrive/connectors/onlyoffice-connector/src/middlewares/error.middleware.ts b/tdrive/connectors/onlyoffice-connector/src/middlewares/error.middleware.ts index e72ba3d20..ceb298092 100644 --- a/tdrive/connectors/onlyoffice-connector/src/middlewares/error.middleware.ts +++ b/tdrive/connectors/onlyoffice-connector/src/middlewares/error.middleware.ts @@ -1,4 +1,4 @@ -import loggerService from '@/services/logger.service'; +import logger from '@/lib/logger'; import { NextFunction, Request, Response } from 'express'; export default (error: Error & { status?: number }, req: Request, res: Response, next: NextFunction): void => { @@ -6,7 +6,7 @@ export default (error: Error & { status?: number }, req: Request, res: Response, const status: number = error.status || 500; const message: string = error.message || 'something went wrong'; - loggerService.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`); + logger.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`, error.stack); res.status(status).json({ message }); } catch (error) { diff --git a/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts b/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts index e92a14ca3..20fb86111 100644 --- a/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts +++ b/tdrive/connectors/onlyoffice-connector/src/routes/index.route.ts @@ -4,6 +4,10 @@ import authMiddleware from '@/middlewares/auth.middleware'; import requirementsMiddleware from '@/middlewares/requirements.middleware'; import { Router } from 'express'; +/** + * 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 router = Router(); diff --git a/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts b/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts index 33c8a9b65..6bc3c879b 100644 --- a/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts +++ b/tdrive/connectors/onlyoffice-connector/src/routes/onlyoffice.route.ts @@ -15,7 +15,7 @@ class OnlyOfficeRoute implements Routes { private initRoutes = () => { this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read); - this.router.post(`${this.path}:mode/save`, requirementsMiddleware, this.onlyOfficeController.save); + this.router.post(`${this.path}:mode/save`, requirementsMiddleware, this.onlyOfficeController.ooCallback); }; } diff --git a/tdrive/connectors/onlyoffice-connector/src/services/api.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/api.service.ts index 6ca0f7afa..fd7bd96ed 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/api.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/api.service.ts @@ -5,31 +5,34 @@ import { IApiServiceApplicationTokenResponse, } from '@/interfaces/api.interface'; import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios'; -import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET, ONLY_OFFICE_SERVER } from '@config'; -import loggerService from './logger.service'; +import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET } from '@config'; +import logger from '../lib/logger'; import * as Utils from '@/utils'; +import { PolledThingieValue } from '@/lib/polled-thingie-value'; -/** Client for the Twake Drive backend API on behalf of the plugin (or provided token in parameters) */ +/** + * Client for the Twake Drive backend API on behalf of the plugin (or provided token in parameters). + * Periodically updates authorization and adds to requests. + */ class ApiService implements IApiService { - private axios: Axios; - private initialized: Promise; + private readonly poller: PolledThingieValue; constructor() { - this.initialized = this.refreshToken(); - this.initialized.catch(error => { - loggerService.error('failed to init API', error); - }); - - setInterval(() => { - this.initialized = this.refreshToken(); - loggerService.info('Refreshing token 🪙'); - }, 1000 * 60); //TODO: should be Every 10 minutes + this.poller = new PolledThingieValue('Refresh Twake Drive token', async () => this.refreshToken(), 1000 * 60); //TODO: should be Every 10 minutes + } + + public async hasToken() { + return (await this.poller.latestValueWithTry()) !== undefined; + } + + private requireAxios() { + return this.poller.requireLatestValueWithTry('Token Kind 538 not ready'); } public get = async (params: IApiServiceRequestParams): Promise => { const { url, token, responseType, headers } = params; - await this.initialized; + const axiosWithToken = await this.requireAxios(); const config: AxiosRequestConfig = {}; @@ -43,35 +46,35 @@ class ApiService implements IApiService { if (responseType) { config['responseType'] = responseType; } - - return await this.axios.get(url, config); + return await axiosWithToken.get(url, config); }; public post = async (params: IApiServiceRequestParams): Promise => { const { url, payload, headers } = params; - await this.initialized; + const axiosWithToken = await this.requireAxios(); + try { - return await this.axios.post(url, payload, { + return await axiosWithToken.post(url, payload, { headers: { ...headers, }, }); } catch (error) { - loggerService.error('Failed to post: ', error.message); + logger.error('Failed to post to Twake drive: ', error.stack); this.refreshToken(); } }; private handleErrors = (error: any): Promise => { - loggerService.error('Failed Request', error.message); + logger.error('Failed Request to Twake drive', error.stack); return Promise.reject(error); }; private handleResponse = ({ data }: AxiosResponse): T => data; - private refreshToken = async (): Promise => { + private refreshToken = async (): Promise => { try { const response = await axios.post( Utils.joinURL([CREDENTIALS_ENDPOINT, '/api/console/v1/login']), @@ -92,25 +95,24 @@ class ApiService implements IApiService { }, } = response.data; - this.axios = axios.create({ + const axiosWithToken = axios.create({ baseURL: CREDENTIALS_ENDPOINT, headers: { Authorization: `Bearer ${value}`, }, }); - this.axios.interceptors.response.use(this.handleResponse, this.handleErrors); + axiosWithToken.interceptors.response.use(this.handleResponse, this.handleErrors); - return value; + return axiosWithToken; } catch (error) { - loggerService.error('failed to get application token', error.message); - loggerService.info('Using token ', CREDENTIALS_ID, CREDENTIALS_SECRET); - loggerService.info(`POST ${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`); - loggerService.info(`Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`); + logger.error('failed to get application token from Twake drive', error.stack); + logger.info('Using token ', CREDENTIALS_ID, CREDENTIALS_SECRET); + logger.info(`POST ${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`); + logger.info(`Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`); throw Error(error); } }; - } export default new ApiService(); diff --git a/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts index 164159ce4..c070ceb91 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/drive.service.ts @@ -1,8 +1,10 @@ import { DriveFileType, IDriveService } from '@/interfaces/drive.interface'; import apiService from './api.service'; -import loggerService from './logger.service'; +import logger from '../lib/logger'; -/** Client for the Twake Drive backend API dealing with `DriveItem`s */ +/** Client for Twake Drive's APIs dealing with `DriveItem`s, using {@see apiService} + * to handle authorization + */ class DriveService implements IDriveService { public get = async (params: { company_id: string; drive_file_id: string; user_token?: string }): Promise => { try { @@ -14,7 +16,7 @@ class DriveService implements IDriveService { return resource; } catch (error) { - loggerService.error('Failed to fetch file metadata: ', error.message); + logger.error('Failed to fetch file metadata: ', error.stack); return Promise.reject(); } @@ -41,7 +43,7 @@ class DriveService implements IDriveService { return resource; } catch (error) { - loggerService.error('Failed to create version: ', error.message); + logger.error('Failed to create version: ', error.stack); return Promise.reject(); } }; diff --git a/tdrive/connectors/onlyoffice-connector/src/services/file.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/file.service.ts index 19c8267c5..9d8e4698c 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/file.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/file.service.ts @@ -1,12 +1,14 @@ import { FileRequestParams, FileType, IFileService } from '@/interfaces/file.interface'; import apiService from './api.service'; -import loggerService from './logger.service'; +import logger from '../lib/logger'; import { Stream } from 'stream'; import FormData from 'form-data'; import * as Utils from '@/utils'; +/** Client for Twake Drive's file related APIs, using {@see apiService} + * to handle authorization + */ class FileService implements IFileService { - public get = async (params: FileRequestParams): Promise => { try { const { company_id, file_id } = params; @@ -16,7 +18,7 @@ class FileService implements IFileService { return resource; } catch (error) { - loggerService.error('Failed to fetch file metadata: ', error.message); + logger.error('Failed to fetch file metadata from Twake Drive: ', error.stack); return Promise.reject(); } @@ -32,7 +34,7 @@ class FileService implements IFileService { return file; } catch (error) { - loggerService.error('Failed to download file: ', error.message); + logger.error('Failed to download file from Twake Drive: ', error.stack); } }; @@ -66,7 +68,7 @@ class FileService implements IFileService { filename, }); - loggerService.info('Saving file version: ', filename); + logger.info('Saving file version to Twake Drive: ', filename); return await apiService.post({ url: create_new @@ -76,7 +78,7 @@ class FileService implements IFileService { headers: form.getHeaders(), }); } catch (error) { - loggerService.error('Failed to save file: ', error.message); + logger.error('Failed to save file: ', error.stack); } }; } diff --git a/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts index a92c0c95c..72a9a161d 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/onlyoffice.service.ts @@ -1,6 +1,7 @@ -import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios from 'axios'; import { ONLY_OFFICE_SERVER } from '@config'; -import loggerService from './logger.service'; +import { PolledThingieValue } from '@/lib/polled-thingie-value'; +import logger from '@/lib/logger'; import * as Utils from '@/utils'; /** @see https://api.onlyoffice.com/editors/basic */ @@ -14,7 +15,7 @@ export enum ErrorCode { INVALID_TOKEN = 6, } /** Return the name of the error code in the `ErrorCode` enum if recognised, or a descript string */ -export const ErrorCodeFromValue = (value: number) => Utils.getKeyForValueSafe(value, ErrorCode, "OnlyOffice.ErrorCode"); +export const ErrorCodeFromValue = (value: number) => Utils.getKeyForValueSafe(value, ErrorCode, 'OnlyOffice.ErrorCode'); /** @see https://api.onlyoffice.com/editors/callback */ export namespace Callback { @@ -22,7 +23,7 @@ export namespace Callback { USER_DISCONNECTED = 0, USER_CONNECTED = 1, USER_INITIATED_FORCE_SAVE = 2, - }; + } enum ForceSaveType { FROM_COMMAND_SERVICE = 0, @@ -31,7 +32,7 @@ export namespace Callback { FORM_SUBMITTED = 3, } - enum Status { + export enum Status { BEING_EDITED = 1, /** `url` field present with this status */ READY_FOR_SAVING = 2, @@ -49,7 +50,7 @@ export namespace Callback { userid: string; } /** Parameters given to the callback by the editing service */ - interface Parameters { + export interface Parameters { key: string; status: Status; filetype?: string; @@ -65,13 +66,23 @@ export namespace Callback { * @see https://api.onlyoffice.com/editors/command/ */ namespace CommandService { - interface BaseResponse { error: ErrorCode; } - interface SuccessResponse extends BaseResponse { error: ErrorCode.SUCCESS; } - interface ErrorResponse extends BaseResponse { error: Exclude; } + interface BaseResponse { + error: ErrorCode; + } + interface SuccessResponse extends BaseResponse { + error: ErrorCode.SUCCESS; + } + interface ErrorResponse extends BaseResponse { + 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)}`); + super( + `OnlyOffice command service error ${ErrorCodeFromValue(errorCode)} (${errorCode}): Requested ${JSON.stringify(req)} got ${JSON.stringify( + res, + )}`, + ); } } @@ -80,44 +91,74 @@ namespace CommandService { /** POST this OnlyOffice command, does not check the `error` field of the response */ async postUnsafe(): Promise { - loggerService.silly(`OnlyOffice command ${this.c} sent: ${JSON.stringify(this)}`); - const result = (await axios.post(`${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`, this)); - loggerService.info(`OnlyOffice command ${this.c} response: ${result.status}: ${JSON.stringify(result.data)}`); + logger.silly(`OnlyOffice command ${this.c} sent: ${JSON.stringify(this)}`); + const result = await axios.post(`${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`, this); + logger.info(`OnlyOffice command ${this.c} response: ${result.status}: ${JSON.stringify(result.data)}`); return result.data as ErrorResponse | TSuccessResponse; } /** POST this request, and return the result, or throw if the `errorCode` returned isn't 0 */ async post(): Promise { const result = await this.postUnsafe(); - if (result.error === ErrorCode.SUCCESS) - return result; + if (result.error === ErrorCode.SUCCESS) return result; throw new CommandError(result.error, this, result); } } export namespace Version { - interface Response extends SuccessResponse { version: string; } - export class Request extends BaseRequest { constructor() { super("version"); } } + interface Response extends SuccessResponse { + version: string; + } + export class Request extends BaseRequest { + constructor() { + super('version'); + } + } } export namespace ForceSave { - interface Response extends SuccessResponse { key: string; } - export class Request extends BaseRequest { constructor(public key: string, public userdata: string = "") { super("forcesave"); } } + interface Response extends SuccessResponse { + key: string; + } + export class Request extends BaseRequest { + constructor(public key: string, public userdata: string = '') { + super('forcesave'); + } + } } export namespace GetForgotten { - interface Response extends SuccessResponse { key: string; url: string; } - export class Request extends BaseRequest { constructor(public key: string) { super("getForgotten"); } } + interface Response extends SuccessResponse { + key: string; + url: string; + } + export class Request extends BaseRequest { + constructor(public key: string) { + super('getForgotten'); + } + } } export namespace GetForgottenList { - interface Response extends SuccessResponse { keys: string[]; } - export class Request extends BaseRequest { constructor() { super("getForgottenList"); } } + interface Response extends SuccessResponse { + keys: string[]; + } + export class Request extends BaseRequest { + constructor() { + super('getForgottenList'); + } + } } export namespace DeleteForgotten { - interface Response extends SuccessResponse { key: string; } - export class Request extends BaseRequest { constructor(public key: string) { super("deleteForgotten"); } } + interface Response extends SuccessResponse { + key: string; + } + export class Request extends BaseRequest { + constructor(public key: string) { + super('deleteForgotten'); + } + } } } @@ -126,12 +167,20 @@ namespace CommandService { * @see https://api.onlyoffice.com/editors/command/ */ class OnlyOfficeService { + private readonly poller: PolledThingieValue; + constructor() { + this.poller = new PolledThingieValue('Connect to Only Office', () => this.getVersion(), 10 * 1000 * 60); + } + /** Get the latest Only Office version */ + public getLatestVersion() { + return this.poller.latest(); + } /** Return the version string of OnlyOffice */ async getVersion(): Promise { return new CommandService.Version.Request().post().then(response => response.version); } /** Force a save in the editing session key provided. `userdata` will be forwarded to the callback */ - async forceSave(key: string, userdata: string = ""): Promise { + async forceSave(key: string, userdata = ''): Promise { return new CommandService.ForceSave.Request(key, userdata).post().then(response => response.key); } /** Return the keys of all forgotten documents in OnlyOffice's document editing service */ diff --git a/tdrive/connectors/onlyoffice-connector/src/services/user.service.ts b/tdrive/connectors/onlyoffice-connector/src/services/user.service.ts index b9de6ce91..75f5f012c 100644 --- a/tdrive/connectors/onlyoffice-connector/src/services/user.service.ts +++ b/tdrive/connectors/onlyoffice-connector/src/services/user.service.ts @@ -1,6 +1,6 @@ import { IuserService, UserType } from '@/interfaces/user.interface'; import apiService from './api.service'; -import loggerService from './logger.service'; +import logger from '../lib/logger'; class UserService implements IuserService { public getCurrentUser = async (token: string): Promise => { @@ -12,7 +12,7 @@ class UserService implements IuserService { return resource; } catch (error) { - loggerService.error('Failed to fetch the current user', error.message); + logger.error('Failed to fetch the current user from Twake Drive', error.stack); return null; } diff --git a/tdrive/connectors/onlyoffice-connector/src/utils.ts b/tdrive/connectors/onlyoffice-connector/src/utils.ts index 414e7af76..094781722 100644 --- a/tdrive/connectors/onlyoffice-connector/src/utils.ts +++ b/tdrive/connectors/onlyoffice-connector/src/utils.ts @@ -1,6 +1,6 @@ /** Return the key in `obj` of which the value matches the `value` parameter, or `undefined` */ export function getKeyForValue(value: T, obj: any): string | undefined { - return (Object.entries(obj as { [key: string]: T }).filter(([_key, entryValue]) => value === entryValue)[0] ?? [])[0]; + return (Object.entries(obj as { [key: string]: T }).filter(([, entryValue]) => value === entryValue)[0] ?? [])[0]; } /** Same as `getKeyForValue` but returns a default string for values not found */ @@ -14,14 +14,12 @@ export type QueryParams = { [key: string]: string | number }; /** Compose a URL removing and adding slashes and query parameters as warranted */ export function joinURL(path: string[], params?: QueryParams) { - let joinedPath = path.map(x => x.replace(/(?:^\/+)+|(?:\/+$)/g, "")).join("/"); - if (path[path.length - 1].endsWith("/")) - joinedPath += "/"; + let joinedPath = path.map(x => x.replace(/(?:^\/+)+|(?:\/+$)/g, '')).join('/'); + if (path[path.length - 1].endsWith('/')) joinedPath += '/'; const paramEntries = Object.entries(params || {}); - if (paramEntries.length === 0) - return joinedPath; - const query = paramEntries.map((p) => p.map(encodeURIComponent).join("=")).join("&"); - return joinedPath + (joinedPath.indexOf("?") > -1 ? "&" : "?") + query; + if (paramEntries.length === 0) return joinedPath; + const query = paramEntries.map(p => p.map(encodeURIComponent).join('=')).join('&'); + return joinedPath + (joinedPath.indexOf('?') > -1 ? '&' : '?') + query; } /** Split a filename into an array `[name, extension]`. Either and both can be @@ -42,8 +40,7 @@ export function joinURL(path: string[], params?: QueryParams) { */ export function splitFilename(filename: string): [string, string] { const parts = filename.split('.'); - if (parts.length < 2 || (parts.length == 2 && parts[0] === "")) - return [filename, ""]; + if (parts.length < 2 || (parts.length == 2 && parts[0] === '')) return [filename, '']; const extension = parts.pop(); - return [parts.join("."), extension]; + return [parts.join('.'), extension]; } diff --git a/tdrive/connectors/onlyoffice-connector/src/views/index.eta b/tdrive/connectors/onlyoffice-connector/src/views/index.eta index f4f6c30f4..a88e8012e 100644 --- a/tdrive/connectors/onlyoffice-connector/src/views/index.eta +++ b/tdrive/connectors/onlyoffice-connector/src/views/index.eta @@ -34,24 +34,6 @@ preview: <%= it.preview %>, } } - const documentChangeHandler = function (event) { - $.ajax({ - url: `${window.baseURL}save?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`, - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ - file_id: "<%= it.file_id %>", - key: "<%= it.file_version_id %>", - token: "<%= it.token %>" - }), - success: function() { - console.log('save success'); - }, - error: function() { - console.log('save error'); - } - }); - } window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', { scrollSensitivity: window.mode === 'text' ? 100 : 40, @@ -80,11 +62,8 @@ }, }, }, - events: { - onDocumentStateChange: documentChangeHandler, - } }); - + });