♻️🎨 connector: light cleanup, load (#525)

renaming logger, adding health endpoint, working with old API,
removing aggressive force save stuff, missing forgotten files etc,
documentation
This commit is contained in:
Eric Doughty-Papassideris
2024-07-10 01:41:00 +02:00
committed by ericlinagora
parent 16dbf8077b
commit 92aad866d5
17 changed files with 300 additions and 172 deletions
@@ -28,6 +28,7 @@
"@typescript-eslint/explicit-module-boundary-types": 0, "@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-types": "off", "@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-var-requires": "off" "@typescript-eslint/no-var-requires": "off"
} }
} }
@@ -6,8 +6,10 @@ import { renderFile } from 'eta';
import path from 'path'; import path from 'path';
import errorMiddleware from './middlewares/error.middleware'; import errorMiddleware from './middlewares/error.middleware';
import { SERVER_PORT, SERVER_PREFIX } from '@config'; import { SERVER_PORT, SERVER_PREFIX } from '@config';
import loggerService from './services/logger.service'; import logger from './lib/logger';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import apiService from './services/api.service';
import onlyofficeService from './services/onlyoffice.service';
class App { class App {
public app: express.Application; public app: express.Application;
@@ -25,8 +27,8 @@ class App {
} }
public listen = () => { public listen = () => {
this.app.listen(SERVER_PORT, () => { this.app.listen(parseInt(SERVER_PORT, 10), '0.0.0.0', () => {
loggerService.info(`🚀 App listening on port ${SERVER_PORT}`); logger.info(`🚀 App listening on port ${SERVER_PORT}`);
}); });
}; };
@@ -34,7 +36,7 @@ class App {
private initRoutes = (routes: Routes[]) => { private initRoutes = (routes: Routes[]) => {
this.app.use((req, res, next) => { 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(); next();
}); });
@@ -42,6 +44,13 @@ class App {
this.app.use(SERVER_PREFIX, route.router); 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( this.app.use(
SERVER_PREFIX.replace(/\/$/, '') + '/assets', SERVER_PREFIX.replace(/\/$/, '') + '/assets',
(req, res, next) => { (req, res, next) => {
@@ -6,7 +6,7 @@ import driveService from '@/services/drive.service';
import { DriveFileType } from '@/interfaces/drive.interface'; import { DriveFileType } from '@/interfaces/drive.interface';
import fileService from '@/services/file.service'; import fileService from '@/services/file.service';
import { OfficeToken } from '@/interfaces/routes.interface'; import { OfficeToken } from '@/interfaces/routes.interface';
import loggerService from '@/services/logger.service'; import logger from '@/lib/logger';
import * as Utils from '@/utils'; import * as Utils from '@/utils';
interface RequestQuery { interface RequestQuery {
@@ -96,7 +96,7 @@ class IndexController {
company_id, company_id,
preview, preview,
office_token: officeToken, office_token: officeToken,
}) }),
); );
} catch (error) { } catch (error) {
next(error); next(error);
@@ -134,7 +134,7 @@ class IndexController {
token: inPageToken, token: inPageToken,
}); });
} catch (error) { } catch (error) {
loggerService.error(error); logger.error(error);
next(error); next(error);
} }
}; };
@@ -1,10 +1,9 @@
import { CREDENTIALS_SECRET } from '@/config'; import { CREDENTIALS_SECRET } from '@/config';
import { OfficeToken } from '@/interfaces/routes.interface'; import { OfficeToken } from '@/interfaces/routes.interface';
import apiService from '@/services/api.service';
import driveService from '@/services/drive.service'; import driveService from '@/services/drive.service';
import fileService from '@/services/file.service'; import fileService from '@/services/file.service';
import loggerService from '@/services/logger.service'; import logger from '@/lib/logger';
import onlyofficeService, * as OnlyOffice from '@/services/onlyoffice.service'; import * as OnlyOffice from '@/services/onlyoffice.service';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
@@ -14,14 +13,6 @@ interface RequestQuery {
token: string; 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 /** These expose a OnlyOffice document storage service methods, called by the OnlyOffice document editing service
* to load and save files * 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. * Parameters are standard Express middleware.
* @see https://api.onlyoffice.com/editors/save * @see https://api.onlyoffice.com/editors/save
* @see https://api.onlyoffice.com/editors/callback * @see https://api.onlyoffice.com/editors/callback
*/ */
public save = async (req: Request<{}, {}, SaveRequestBody, RequestQuery>, res: Response, next: NextFunction): Promise<void> => { public ooCallback = async (
req: Request<{}, {}, OnlyOffice.Callback.Parameters, RequestQuery>,
res: Response,
next: NextFunction,
): Promise<void> => {
const respondToOO = (error = 0) => void res.send({ error });
try { try {
const { url, key } = req.body; const { url, key } = req.body;
const { token } = req.query; 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 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 // 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 (!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 (preview) throw new Error('Invalid token, must not be a preview token for save operation');
if (url) { switch (req.body.status) {
loggerService.info('URL present, saving file'); case OnlyOffice.Callback.Status.BEING_EDITED:
// If token indicate a drive_file_id then check if we want to create a new version or not case OnlyOffice.Callback.Status.BEING_EDITED_BUT_IS_SAVED:
if (drive_file_id) { // No-op
loggerService.info('Drive file id present, checking if we need to create a new version'); break;
//Get the drive file
const driveFile = await driveService.get({ case OnlyOffice.Callback.Status.READY_FOR_SAVING:
const newVersionFile = await fileService.save({
company_id, company_id,
drive_file_id, file_id,
url,
create_new: true,
}); });
// 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 version = await driveService.createVersion({
const createNewVersion = true; company_id,
if (createNewVersion) { drive_file_id,
const newVersionFile = await fileService.save({ file_id: newVersionFile?.resource?.id,
company_id, });
file_id, logger.info('New version created', version);
url, return respondToOO();
create_new: true,
});
await driveService.createVersion({ case OnlyOffice.Callback.Status.CLOSED_WITHOUT_CHANGES:
company_id, // Save end of transaction
drive_file_id, break;
file_id: newVersionFile?.resource?.id,
});
loggerService.info('New version created');
res.send({ case OnlyOffice.Callback.Status.ERROR_SAVING:
error: 0, // Save end of transaction
}); logger.info(`Error saving file ${req.body.url} (key: ${req.body.key})`);
break;
return; 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})`);
loggerService.info('Saving file'); return void res.send({ error: 0 });
await fileService.save({ default:
company_id, throw new Error(`Unexpected OO Callback status field: ${req.body.status}`);
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);
});
} }
return respondToOO();
res.send({
error: 0,
});
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -0,0 +1,98 @@
import logger from './logger';
const now = () => new Date().getTime();
type NotUndefined<T> = 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<ValueType> {
private lastOkTimeMs: number | undefined;
private lastKoTimeMs: number | undefined;
protected lastValue: ValueType | undefined;
protected lastKoError: any;
protected pendingTry: Promise<ValueType | undefined> | undefined;
constructor(
private readonly logPrefix: string,
private readonly getTheThingieValue: () => Promise<NotUndefined<ValueType>>,
private readonly intervalMs: number,
) {
this.run();
setInterval(() => this.run(), this.intervalMs);
}
protected setResult(value: undefined, error?: NotUndefined<ValueType>, ts?: number);
protected setResult(value: NotUndefined<ValueType>, 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<ValueType | undefined> {
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<ValueType> {
const result = await this.latestValueWithTry();
if (result === undefined) throw new Error(errorMessage);
return result;
}
}
@@ -1,5 +1,5 @@
import { CREDENTIALS_SECRET } from '@/config'; import { CREDENTIALS_SECRET } from '@/config';
import loggerService from '@/services/logger.service'; import logger from '@/lib/logger';
import userService from '@/services/user.service'; import userService from '@/services/user.service';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
@@ -25,7 +25,7 @@ export default async (req: Request<{}, {}, {}, RequestQuery>, res: Response, nex
return; return;
} }
} catch (error) { } catch (error) {
loggerService.error('invalid token', error); logger.error('invalid token', error.stack);
res.clearCookie('token'); res.clearCookie('token');
} }
} }
@@ -1,4 +1,4 @@
import loggerService from '@/services/logger.service'; import logger from '@/lib/logger';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
export default (error: Error & { status?: number }, req: Request, res: Response, next: NextFunction): void => { 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 status: number = error.status || 500;
const message: string = error.message || 'something went wrong'; 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 }); res.status(status).json({ message });
} catch (error) { } catch (error) {
@@ -4,6 +4,10 @@ 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';
/**
* 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 { class IndexRoute implements Routes {
public path = '/'; public path = '/';
public router = Router(); public router = Router();
@@ -15,7 +15,7 @@ class OnlyOfficeRoute implements Routes {
private initRoutes = () => { private initRoutes = () => {
this.router.get(`${this.path}:mode/read`, requirementsMiddleware, this.onlyOfficeController.read); 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);
}; };
} }
@@ -5,31 +5,34 @@ import {
IApiServiceApplicationTokenResponse, IApiServiceApplicationTokenResponse,
} from '@/interfaces/api.interface'; } from '@/interfaces/api.interface';
import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios';
import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET, ONLY_OFFICE_SERVER } from '@config'; import { CREDENTIALS_ENDPOINT, CREDENTIALS_ID, CREDENTIALS_SECRET } from '@config';
import loggerService from './logger.service'; import logger from '../lib/logger';
import * as Utils from '@/utils'; 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 { class ApiService implements IApiService {
private axios: Axios; private readonly poller: PolledThingieValue<Axios>;
private initialized: Promise<string>;
constructor() { constructor() {
this.initialized = this.refreshToken(); this.poller = new PolledThingieValue('Refresh Twake Drive token', async () => this.refreshToken(), 1000 * 60); //TODO: should be Every 10 minutes
this.initialized.catch(error => { }
loggerService.error('failed to init API', error);
});
setInterval(() => { public async hasToken() {
this.initialized = this.refreshToken(); return (await this.poller.latestValueWithTry()) !== undefined;
loggerService.info('Refreshing token 🪙'); }
}, 1000 * 60); //TODO: should be Every 10 minutes
private requireAxios() {
return this.poller.requireLatestValueWithTry('Token Kind 538 not ready');
} }
public get = async <T>(params: IApiServiceRequestParams<T>): Promise<T> => { public get = async <T>(params: IApiServiceRequestParams<T>): Promise<T> => {
const { url, token, responseType, headers } = params; const { url, token, responseType, headers } = params;
await this.initialized; const axiosWithToken = await this.requireAxios();
const config: AxiosRequestConfig = {}; const config: AxiosRequestConfig = {};
@@ -43,35 +46,35 @@ class ApiService implements IApiService {
if (responseType) { if (responseType) {
config['responseType'] = responseType; config['responseType'] = responseType;
} }
return await axiosWithToken.get(url, config);
return await this.axios.get(url, config);
}; };
public post = async <T, R>(params: IApiServiceRequestParams<T>): Promise<R> => { public post = async <T, R>(params: IApiServiceRequestParams<T>): Promise<R> => {
const { url, payload, headers } = params; const { url, payload, headers } = params;
await this.initialized; const axiosWithToken = await this.requireAxios();
try { try {
return await this.axios.post(url, payload, { return await axiosWithToken.post(url, payload, {
headers: { headers: {
...headers, ...headers,
}, },
}); });
} catch (error) { } catch (error) {
loggerService.error('Failed to post: ', error.message); logger.error('Failed to post to Twake drive: ', error.stack);
this.refreshToken(); this.refreshToken();
} }
}; };
private handleErrors = (error: any): Promise<any> => { private handleErrors = (error: any): Promise<any> => {
loggerService.error('Failed Request', error.message); logger.error('Failed Request to Twake drive', error.stack);
return Promise.reject(error); return Promise.reject(error);
}; };
private handleResponse = <T>({ data }: AxiosResponse): T => data; private handleResponse = <T>({ data }: AxiosResponse): T => data;
private refreshToken = async (): Promise<string> => { private refreshToken = async (): Promise<Axios> => {
try { try {
const response = await axios.post<IApiServiceApplicationTokenRequestParams, { data: IApiServiceApplicationTokenResponse }>( const response = await axios.post<IApiServiceApplicationTokenRequestParams, { data: IApiServiceApplicationTokenResponse }>(
Utils.joinURL([CREDENTIALS_ENDPOINT, '/api/console/v1/login']), Utils.joinURL([CREDENTIALS_ENDPOINT, '/api/console/v1/login']),
@@ -92,25 +95,24 @@ class ApiService implements IApiService {
}, },
} = response.data; } = response.data;
this.axios = axios.create({ const axiosWithToken = axios.create({
baseURL: CREDENTIALS_ENDPOINT, baseURL: CREDENTIALS_ENDPOINT,
headers: { headers: {
Authorization: `Bearer ${value}`, 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) { } catch (error) {
loggerService.error('failed to get application token', error.message); logger.error('failed to get application token from Twake drive', error.stack);
loggerService.info('Using token ', CREDENTIALS_ID, CREDENTIALS_SECRET); logger.info('Using token ', CREDENTIALS_ID, CREDENTIALS_SECRET);
loggerService.info(`POST ${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`); logger.info(`POST ${CREDENTIALS_ENDPOINT.replace(/\/$/, '')}/api/console/v1/login`);
loggerService.info(`Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`); logger.info(`Basic ${Buffer.from(`${CREDENTIALS_ID}:${CREDENTIALS_SECRET}`).toString('base64')}`);
throw Error(error); throw Error(error);
} }
}; };
} }
export default new ApiService(); export default new ApiService();
@@ -1,8 +1,10 @@
import { DriveFileType, IDriveService } from '@/interfaces/drive.interface'; import { DriveFileType, IDriveService } from '@/interfaces/drive.interface';
import apiService from './api.service'; 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 { class DriveService implements IDriveService {
public get = async (params: { company_id: string; drive_file_id: string; user_token?: string }): Promise<DriveFileType> => { public get = async (params: { company_id: string; drive_file_id: string; user_token?: string }): Promise<DriveFileType> => {
try { try {
@@ -14,7 +16,7 @@ class DriveService implements IDriveService {
return resource; return resource;
} catch (error) { } catch (error) {
loggerService.error('Failed to fetch file metadata: ', error.message); logger.error('Failed to fetch file metadata: ', error.stack);
return Promise.reject(); return Promise.reject();
} }
@@ -41,7 +43,7 @@ class DriveService implements IDriveService {
return resource; return resource;
} catch (error) { } catch (error) {
loggerService.error('Failed to create version: ', error.message); logger.error('Failed to create version: ', error.stack);
return Promise.reject(); return Promise.reject();
} }
}; };
@@ -1,12 +1,14 @@
import { FileRequestParams, FileType, IFileService } from '@/interfaces/file.interface'; import { FileRequestParams, FileType, IFileService } from '@/interfaces/file.interface';
import apiService from './api.service'; import apiService from './api.service';
import loggerService from './logger.service'; import logger from '../lib/logger';
import { Stream } from 'stream'; import { Stream } from 'stream';
import FormData from 'form-data'; import FormData from 'form-data';
import * as Utils from '@/utils'; import * as Utils from '@/utils';
/** Client for Twake Drive's file related APIs, using {@see apiService}
* to handle authorization
*/
class FileService implements IFileService { class FileService implements IFileService {
public get = async (params: FileRequestParams): Promise<FileType> => { public get = async (params: FileRequestParams): Promise<FileType> => {
try { try {
const { company_id, file_id } = params; const { company_id, file_id } = params;
@@ -16,7 +18,7 @@ class FileService implements IFileService {
return resource; return resource;
} catch (error) { } 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(); return Promise.reject();
} }
@@ -32,7 +34,7 @@ class FileService implements IFileService {
return file; return file;
} catch (error) { } 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, filename,
}); });
loggerService.info('Saving file version: ', filename); logger.info('Saving file version to Twake Drive: ', filename);
return await apiService.post<any, { resource: FileType }>({ return await apiService.post<any, { resource: FileType }>({
url: create_new url: create_new
@@ -76,7 +78,7 @@ class FileService implements IFileService {
headers: form.getHeaders(), headers: form.getHeaders(),
}); });
} catch (error) { } catch (error) {
loggerService.error('Failed to save file: ', error.message); logger.error('Failed to save file: ', error.stack);
} }
}; };
} }
@@ -1,6 +1,7 @@
import axios, { Axios, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios from 'axios';
import { ONLY_OFFICE_SERVER } from '@config'; 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'; import * as Utils from '@/utils';
/** @see https://api.onlyoffice.com/editors/basic */ /** @see https://api.onlyoffice.com/editors/basic */
@@ -14,7 +15,7 @@ export enum ErrorCode {
INVALID_TOKEN = 6, INVALID_TOKEN = 6,
} }
/** Return the name of the error code in the `ErrorCode` enum if recognised, or a descript string */ /** 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 */ /** @see https://api.onlyoffice.com/editors/callback */
export namespace Callback { export namespace Callback {
@@ -22,7 +23,7 @@ export namespace Callback {
USER_DISCONNECTED = 0, USER_DISCONNECTED = 0,
USER_CONNECTED = 1, USER_CONNECTED = 1,
USER_INITIATED_FORCE_SAVE = 2, USER_INITIATED_FORCE_SAVE = 2,
}; }
enum ForceSaveType { enum ForceSaveType {
FROM_COMMAND_SERVICE = 0, FROM_COMMAND_SERVICE = 0,
@@ -31,7 +32,7 @@ export namespace Callback {
FORM_SUBMITTED = 3, FORM_SUBMITTED = 3,
} }
enum Status { export enum Status {
BEING_EDITED = 1, BEING_EDITED = 1,
/** `url` field present with this status */ /** `url` field present with this status */
READY_FOR_SAVING = 2, READY_FOR_SAVING = 2,
@@ -49,7 +50,7 @@ export namespace Callback {
userid: string; userid: string;
} }
/** Parameters given to the callback by the editing service */ /** Parameters given to the callback by the editing service */
interface Parameters { export interface Parameters {
key: string; key: string;
status: Status; status: Status;
filetype?: string; filetype?: string;
@@ -65,13 +66,23 @@ export namespace Callback {
* @see https://api.onlyoffice.com/editors/command/ * @see https://api.onlyoffice.com/editors/command/
*/ */
namespace CommandService { namespace CommandService {
interface BaseResponse { error: ErrorCode; } interface BaseResponse {
interface SuccessResponse extends BaseResponse { error: ErrorCode.SUCCESS; } error: ErrorCode;
interface ErrorResponse extends BaseResponse { error: Exclude<ErrorCode, ErrorCode.SUCCESS>; } }
interface SuccessResponse extends BaseResponse {
error: ErrorCode.SUCCESS;
}
interface ErrorResponse extends BaseResponse {
error: Exclude<ErrorCode, ErrorCode.SUCCESS>;
}
export class CommandError extends Error { export class CommandError extends Error {
constructor(errorCode: ErrorCode, req: any, res: any) { 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 */ /** POST this OnlyOffice command, does not check the `error` field of the response */
async postUnsafe(): Promise<ErrorResponse | TSuccessResponse> { async postUnsafe(): Promise<ErrorResponse | TSuccessResponse> {
loggerService.silly(`OnlyOffice command ${this.c} sent: ${JSON.stringify(this)}`); logger.silly(`OnlyOffice command ${this.c} sent: ${JSON.stringify(this)}`);
const result = (await axios.post(`${ONLY_OFFICE_SERVER}coauthoring/CommandService.ashx`, 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.info(`OnlyOffice command ${this.c} response: ${result.status}: ${JSON.stringify(result.data)}`);
return result.data as ErrorResponse | TSuccessResponse; return result.data as ErrorResponse | TSuccessResponse;
} }
/** POST this request, and return the result, or throw if the `errorCode` returned isn't 0 */ /** POST this request, and return the result, or throw if the `errorCode` returned isn't 0 */
async post(): Promise<TSuccessResponse> { async post(): Promise<TSuccessResponse> {
const result = await this.postUnsafe(); const result = await this.postUnsafe();
if (result.error === ErrorCode.SUCCESS) if (result.error === ErrorCode.SUCCESS) return result;
return result;
throw new CommandError(result.error, this, result); throw new CommandError(result.error, this, result);
} }
} }
export namespace Version { export namespace Version {
interface Response extends SuccessResponse { version: string; } interface Response extends SuccessResponse {
export class Request extends BaseRequest<Response> { constructor() { super("version"); } } version: string;
}
export class Request extends BaseRequest<Response> {
constructor() {
super('version');
}
}
} }
export namespace ForceSave { export namespace ForceSave {
interface Response extends SuccessResponse { key: string; } interface Response extends SuccessResponse {
export class Request extends BaseRequest<Response> { constructor(public key: string, public userdata: string = "") { super("forcesave"); } } key: string;
}
export class Request extends BaseRequest<Response> {
constructor(public key: string, public userdata: string = '') {
super('forcesave');
}
}
} }
export namespace GetForgotten { export namespace GetForgotten {
interface Response extends SuccessResponse { key: string; url: string; } interface Response extends SuccessResponse {
export class Request extends BaseRequest<Response> { constructor(public key: string) { super("getForgotten"); } } key: string;
url: string;
}
export class Request extends BaseRequest<Response> {
constructor(public key: string) {
super('getForgotten');
}
}
} }
export namespace GetForgottenList { export namespace GetForgottenList {
interface Response extends SuccessResponse { keys: string[]; } interface Response extends SuccessResponse {
export class Request extends BaseRequest<Response> { constructor() { super("getForgottenList"); } } keys: string[];
}
export class Request extends BaseRequest<Response> {
constructor() {
super('getForgottenList');
}
}
} }
export namespace DeleteForgotten { export namespace DeleteForgotten {
interface Response extends SuccessResponse { key: string; } interface Response extends SuccessResponse {
export class Request extends BaseRequest<Response> { constructor(public key: string) { super("deleteForgotten"); } } key: string;
}
export class Request extends BaseRequest<Response> {
constructor(public key: string) {
super('deleteForgotten');
}
}
} }
} }
@@ -126,12 +167,20 @@ namespace CommandService {
* @see https://api.onlyoffice.com/editors/command/ * @see https://api.onlyoffice.com/editors/command/
*/ */
class OnlyOfficeService { class OnlyOfficeService {
private readonly poller: PolledThingieValue<string>;
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 */ /** Return the version string of OnlyOffice */
async getVersion(): Promise<string> { async getVersion(): Promise<string> {
return new CommandService.Version.Request().post().then(response => response.version); 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 */ /** Force a save in the editing session key provided. `userdata` will be forwarded to the callback */
async forceSave(key: string, userdata: string = ""): Promise<string> { async forceSave(key: string, userdata = ''): Promise<string> {
return new CommandService.ForceSave.Request(key, userdata).post().then(response => response.key); 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 */ /** Return the keys of all forgotten documents in OnlyOffice's document editing service */
@@ -1,6 +1,6 @@
import { IuserService, UserType } from '@/interfaces/user.interface'; import { IuserService, UserType } from '@/interfaces/user.interface';
import apiService from './api.service'; import apiService from './api.service';
import loggerService from './logger.service'; import logger from '../lib/logger';
class UserService implements IuserService { class UserService implements IuserService {
public getCurrentUser = async (token: string): Promise<UserType> => { public getCurrentUser = async (token: string): Promise<UserType> => {
@@ -12,7 +12,7 @@ class UserService implements IuserService {
return resource; return resource;
} catch (error) { } 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; return null;
} }
@@ -1,6 +1,6 @@
/** Return the key in `obj` of which the value matches the `value` parameter, or `undefined` */ /** Return the key in `obj` of which the value matches the `value` parameter, or `undefined` */
export function getKeyForValue<T>(value: T, obj: any): string | undefined { export function getKeyForValue<T>(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 */ /** 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 */ /** Compose a URL removing and adding slashes and query parameters as warranted */
export function joinURL(path: string[], params?: QueryParams) { export function joinURL(path: string[], params?: QueryParams) {
let joinedPath = path.map(x => x.replace(/(?:^\/+)+|(?:\/+$)/g, "")).join("/"); let joinedPath = path.map(x => x.replace(/(?:^\/+)+|(?:\/+$)/g, '')).join('/');
if (path[path.length - 1].endsWith("/")) if (path[path.length - 1].endsWith('/')) joinedPath += '/';
joinedPath += "/";
const paramEntries = Object.entries(params || {}); const paramEntries = Object.entries(params || {});
if (paramEntries.length === 0) if (paramEntries.length === 0) return joinedPath;
return joinedPath; const query = paramEntries.map(p => p.map(encodeURIComponent).join('=')).join('&');
const query = paramEntries.map((p) => p.map(encodeURIComponent).join("=")).join("&"); return joinedPath + (joinedPath.indexOf('?') > -1 ? '&' : '?') + query;
return joinedPath + (joinedPath.indexOf("?") > -1 ? "&" : "?") + query;
} }
/** Split a filename into an array `[name, extension]`. Either and both can be /** 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] { export function splitFilename(filename: string): [string, string] {
const parts = filename.split('.'); const parts = filename.split('.');
if (parts.length < 2 || (parts.length == 2 && parts[0] === "")) if (parts.length < 2 || (parts.length == 2 && parts[0] === '')) return [filename, ''];
return [filename, ""];
const extension = parts.pop(); const extension = parts.pop();
return [parts.join("."), extension]; return [parts.join('.'), extension];
} }
@@ -34,24 +34,6 @@
preview: <%= it.preview %>, 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', { window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', {
scrollSensitivity: window.mode === 'text' ? 100 : 40, scrollSensitivity: window.mode === 'text' ? 100 : 40,
@@ -80,9 +62,6 @@
}, },
}, },
}, },
events: {
onDocumentStateChange: documentChangeHandler,
}
}); });
}); });